8
n8n 中文网amn8n.com

AI招聘官 - 多简历分析器

高级

这是一个HR, AI Summarization领域的自动化工作流,包含 18 个节点。主要使用 If, Code, Webhook, SplitInBatches, Agent 等节点。 使用OpenAI GPT分析多份简历与职位描述的匹配度

前置要求
  • HTTP Webhook 端点(n8n 会自动生成)
  • OpenAI API Key
工作流预览
可视化展示节点连接关系,支持缩放和平移
导出工作流
复制以下 JSON 配置到 n8n 导入,即可使用此工作流
{
  "id": "iDDUv4QXan1FQAx5",
  "meta": {
    "instanceId": "01ec604fa4293a8db3ec193f3cc15d3de221decf0ea6072f2f2ae0b8307f4988",
    "templateCredsSetupCompleted": true
  },
  "name": "AI 招聘官 – 多简历分析器",
  "tags": [],
  "nodes": [
    {
      "id": "613e9242-0f92-40a4-a31b-2437c4d71c78",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -1808,
        16
      ],
      "webhookId": "2858c076-fe94-4920-a1e9-014b49b70dfe",
      "parameters": {
        "path": "chat-new",
        "options": {
          "allowedOrigins": "*"
        },
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "7f7a7318-cb2f-4c47-bc6f-108e307f04d1",
      "name": "响应 Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1008,
        -128
      ],
      "parameters": {
        "options": {
          "responseCode": 200,
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type"
              },
              {
                "name": "Access-Control-Allow-Methods",
                "value": "POST, OPTIONS"
              }
            ]
          }
        },
        "respondWith": "allIncomingItems"
      },
      "typeVersion": 1.4
    },
    {
      "id": "6330221c-ec10-4c28-a05e-8a1c8640a3ad",
      "name": "AI 招聘官代理",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        432,
        -128
      ],
      "parameters": {
        "text": "=JD cần tuyển:\n{{ $json.jd }}\n\n---\n\n🎯 Nhiệm vụ:\nBạn là AI Recruiter, có nhiệm vụ so sánh 1 JD với nhiều CV.  \nHãy đọc và hiểu JD ở trên để xác định:\n- jd_position: Tên vị trí hoặc chức danh cần tuyển.  \n- jd_domain: Ngành nghề (Kế toán, Nhân sự, ERP Technical, ERP Functional, Sản xuất, Hành chánh, v.v.)  \n- jd_function: Mảng công việc chính (VD: Hành chính tổng hợp, Tuyển dụng, ERP System Integration...).\n\nSau đó, hãy phân tích từng CV trong danh sách dưới đây:\n- Trích xuất **tên ứng viên thật** từ phần header, phần “Curriculum Vitae”, “Thông tin cá nhân” hoặc các đoạn đầu.  \n- Xác định đúng chức danh, ngành nghề, kỹ năng, kinh nghiệm, vai trò của ứng viên (cv_position, cv_domain, cv_function).  \n- So sánh logic giữa JD và CV: nếu khác ngành hoặc vai trò → domain_match = false.  \n- Tính fit_score dựa trên các tiêu chí domain, kỹ năng, kinh nghiệm, vai trò.  \n- Viết strengths, weaknesses, recommendation, và final_decision.  \n- Mọi dữ liệu phải dựa trên văn bản thật, **không được bịa, suy diễn, hoặc đổi tên ứng viên.**\n\n---\n\n📁 Danh sách CV:\n{{ JSON.stringify($json.candidates) }}\n\n---\n\n📌 Quy tắc bắt buộc:\n\n1️⃣ Nếu CV có nội dung text → trích xuất **tên ứng viên thật** từ phần đầu CV hoặc phần “Thông tin cá nhân”, “Curriculum Vitae”, “Profile”.  \n2️⃣ Nếu CV là file scan hoặc không có text → lấy tên từ **filename**, bỏ đi các phần “CURRICULUM VITAE”, “CV”, “_”, “.pdf”, số thứ tự hoặc ký tự thừa.  \n   Ví dụ:\n   - \"CURRICULUM_VITAE_NGUYEN_THI_NGOC_PHUONG.pdf\" → “Nguyễn Thị Ngọc Phương”  \n   - \"CV_HR_NguyenThiDiemKieu.pdf\" → “Nguyễn Thị Diễm Kiều”  \n3️⃣ Không được tự bịa tên như “Nguyễn Văn A”, “Candidate 1”, “Nguyễn Thị Lan” nếu không có trong CV.  \n4️⃣ Nếu CV rỗng → fit_score = 0, final_decision = \"Loại\".  \n\n---\n\n🧭 ONTOLOGY PHÂN LOẠI NGHỀ & VAI TRÒ:\n\n**1. Vai trò / role_match_type**\n- Nếu CV có các cue: [\"Team Lead\", \"Team Leader\", \"Lead\", \"Manager\", \"Head of\", \"Trưởng\", \"Quản lý\"] → `\"role_match_type\": \"Manager\"`.\n- Nếu có cue kỹ thuật: [\"ABAP\", \"Developer\", \"Coding\", \"Integration\", \"Interface\", \"PI/PO\", \"iFlow\", \"API\", \"Webservice\", \"Basis\", \"Technical Consultant\"] → `\"role_match_type\": \"Technical\"`.\n- Nếu có cue nghiệp vụ ERP: [\"Functional\", \"Consultant\", \"FI/CO\", \"MM\", \"SD\", \"PP\", \"QM\", \"WM\", \"EWM\", \"PM\", \"HR\", \"HCM\", \"FICO\"] → `\"role_match_type\": \"Functional\"`.\n\n**2. Ngành nghề / cv_domain**\n- Nếu có Manager cue → “ERP/IT Manager”\n- Nếu có Technical cue → “CNTT/ERP Technical”\n- Nếu có Functional cue → “CNTT/ERP Functional”\n- Nếu có HR/Recruitment/Admin cue: [\"HR\", \"Recruiter\", \"C&B\", \"Payroll\", \"Hành chính\", \"Admin\"] → “Nhân sự”\n- Nếu có Accounting cue: [\"Accountant\", \"Kế toán\", \"Chief Accountant\", \"GL\", \"Financial Reporting\", \"Cost\"] → “Kế toán”\n- Nếu không xác định được → “Khác”\n\n**3. Xử lý domain_match**\n- Nếu JD và CV khác ngành → domain_match = false, fit_score ≤ 25.  \n- Nếu JD và CV cùng ngành nhưng khác vai trò (Functional ↔ Technical) → fit_score ≤ 60.  \n- Nếu CV là Manager trong cùng domain → fit_score = 70–90.  \n- Không cho fit_score = 100.  \n- Không chấm điểm nếu không có nội dung thực tế.\n- So sánh logic giữa JD và CV:\n   + Nếu jd_domain và cv_domain giống nhau (hoặc gần nghĩa như “HR” ~ “Nhân sự”) → domain_match = true.\n   + Nếu khác ngành rõ ràng → domain_match = false.\n   + Tuyệt đối không đánh false khi hai giá trị giống nhau về nghĩa.\n\n---\n\n📊 Định dạng đầu ra (JSON Array, không giải thích thêm):\n\n[\n  {\n    \"candidate_name\": \"\",\n    \"fit_score\": 0,\n    \"jd_position\": \"\",\n    \"jd_domain\": \"\",\n    \"jd_function\": \"\",\n    \"cv_position\": \"\",\n    \"cv_domain\": \"\",\n    \"cv_function\": \"\",\n    \"domain_match\": false,\n    \"role_match_type\": \"\",\n    \"matched_keywords\": [],\n    \"strengths\": \"\",\n    \"weaknesses\": \"\",\n    \"recommendation\": \"\",\n    \"final_decision\": \"\",\n    \"cv_title\": \"\",\n    \"cv_roles\": [],\n    \"cv_years\": 0,\n    \"evidence\": []\n  }\n]\n\n---\n\n📈 Kết luận:\n- Nếu không có CV nào fit_score ≥ 70 → `\"summary\": \"Không có hồ sơ phù hợp. Đề xuất chọn CV khác.\"`  \n- Nếu có ≥1 CV đủ điều kiện → `\"summary\": \"🏆 Ứng viên xuất sắc nhất: [Tên] ([Điểm]%)\"`\n\n---\n\n🧱 Ghi nhớ:\n- Tuyệt đối KHÔNG đổi tên ứng viên.  \n- Nếu không xác định được tên → để `\"candidate_name\": \"\"`.  \n- Không được tạo ra ứng viên giả hoặc điền thông tin không có trong CV.  \n- Khi phân loại domain hoặc role, nếu có bằng chứng trong text (header, chức danh, kỹ năng) thì bắt buộc trích ra vào `\"evidence\"`.\n- Không sinh ra ký tự rác như `\\\"`, `\\\\n`, hoặc các ký tự điều khiển trong JSON.\n",
        "options": {
          "systemMessage": "🎯 MỤC TIÊU\nBạn là AI Recruiter, có nhiệm vụ so sánh 1 JD với nhiều CV và chấm điểm mức độ phù hợp.ra kết quả đánh giá logic, chi tiết và có chứng cứ thực tế.\n\n⚠️ QUAN TRỌNG:\n- Luôn trả lời và mô tả kết quả bằng **tiếng Việt**, kể cả khi CV hoặc JD là tiếng Anh.\n- Giữ nguyên tên, chức danh, kỹ năng, thuật ngữ chuyên ngành (ví dụ: SAP, Integration, Webservice).\n- Không dịch các từ kỹ thuật, nhưng mọi phần nhận xét, điểm mạnh, điểm yếu, gợi ý đều viết tiếng Việt tự nhiên, chuyên nghiệp.\n\n---\n- Xác định đúng ngành nghề của JD (jd_domain) và từng CV (cv_domain).\n- Đánh giá fit_score dựa trên mức trùng khớp ngành, kỹ năng, kinh nghiệm và vai trò (role).\n- Chỉ đề xuất phỏng vấn (final_decision = \"Interview\") khi fit_score ≥ 70 và domain_match = true.\n\n---\n\n⚙️ BƯỚC 1 — PHÂN LOẠI NGÀNH NGHỀ (DOMAIN INFERENCE)\n\n1️⃣ jd_domain:\n   - Suy ra từ mô tả JD: vị trí, kỹ năng, chức danh, ngữ cảnh công việc.\n   - Nếu JD chứa \"hành chánh\", \"hành chính\", \"admin\", \"office\" → domain = Nhân sự.\n   - Nếu JD chứa \"kho\", \"thủ kho\", \"inventory\", \"warehouse\" → domain = Logistics / Supply Chain.\n   - Nếu JD chứa \"QA\", \"QC\", \"KCS\", \"chất lượng\" → domain = Sản xuất / Chất lượng.\n\n2️⃣ cv_domain:\n   - Chỉ dựa trên nội dung thật của CV, không được sao chép từ JD.\n\n📚 JOB ONTOLOGY (Cơ sở tri thức ngành nghề)\n\n| Vị trí / Chức danh mẫu | Ngành nghề (domain) | Chức năng (function) |\n|-------------------------|---------------------|----------------------|\n| Developer, ABAP, Integration, Technical Consultant | CNTT / ERP Technical | Phát triển, kỹ thuật hệ thống |\n| FI/MM/SD Functional Consultant, ERP Business Analyst | CNTT / ERP Functional | Phân tích nghiệp vụ ERP |\n| ERP/IT Manager, Project Manager, Implementation Lead | ERP / IT Manager | Quản lý dự án ERP |\n| Kế toán, Chief Accountant, Cost Controller | Kế toán | Tài chính, ghi sổ, báo cáo |\n| Nhân sự, HR Admin, C&B, Payroll | Nhân sự | Tuyển dụng, hành chính, lương thưởng |\n| Nhân viên hành chánh, Office Admin, Administrative Staff | Nhân sự | Hành chính tổng hợp |\n| Nhân viên kho, Quản lý kho, Thủ kho | Logistics / Supply Chain | Quản lý tồn kho, vận hành kho |\n| Nhân viên sản xuất, Quản đốc, Giám sát xưởng | Sản xuất | Quản lý sản xuất |\n| QA, QC, KCS, Kiểm tra chất lượng | Sản xuất / Chất lượng | Đảm bảo chất lượng |\n| Nhân viên kinh doanh, Sales Executive, Account Manager | Kinh doanh | Bán hàng, khách hàng |\n| Marketing, Digital Marketing | Marketing | Thương hiệu, quảng bá |\n| Quản lý dự án, Director, General Manager | Quản lý | Lập kế hoạch, điều hành |\n| Khác | Không nhận diện được ngành nghề | Khác (Other) |\n\n---\n\n🧠 BƯỚC 2 — LOGIC DOMAIN MATCH\n\n| Trường hợp | domain_match | fit_score tối đa | Ghi chú |\n|-------------|--------------|------------------|----------|\n| JD = Kế toán, CV = CNTT/ERP | ❌ | 25 | Sai ngành |\n| JD = Technical, CV = Functional (hoặc ngược lại) | ✅ | 60 | Cùng ERP khác vai trò |\n| JD = ERP Technical, CV = ERP/IT Manager | ✅ | 90 | Quản lý cùng ngành |\n| JD = Nhân sự, CV = Hành chánh | ✅ | 75 | Gần nghĩa |\n| JD = Sản xuất, CV = QA/QC | ✅ | 70 | Cùng khối sản xuất |\n| Không rõ ngành | ❌ | 25 | Thiếu dữ liệu |\n\n---\n\n📊 BƯỚC 3 — QUY TẮC CHẤM ĐIỂM (SCORING)\n\n1. fit_score = 0 (ban đầu)\n2. domain_match = true → +50\n3. matched_keywords từ JD xuất hiện trong CV → +10–30\n4. Có ≥3 năm kinh nghiệm → +10\n5. Vai trò Manager/Lead cùng ngành → +10\n6. domain_match = false → fit_score ≤ 25\n7. fit_score tối đa 95 (không bao giờ 100)\n\n---\n\n🔍 BƯỚC 4 — BẰNG CHỨNG (EVIDENCE)\n\nMỗi ứng viên phải có:\n{\n  \"cv_title\": \"ERP Manager\",\n  \"cv_roles\": [\"ERP Manager\", \"Technical Lead\"],\n  \"cv_years\": 8,\n  \"evidence\": [\n    \"ERP Manager tại công ty ABC, quản lý triển khai SAP\",\n    \"Kinh nghiệm 8 năm về ABAP, Integration, Webservice\"\n  ]\n}\nNếu không có chứng cứ rõ ràng → domain_match = false, fit_score ≤ 25, final_decision = \"Loại\".\n\n---\n\n🧱 BƯỚC 5 — CHỐNG NHIỄM JD (ANTI-LEAK)\n\n- Không dùng từ khóa trong JD để xác định cv_domain, strengths hoặc matched_keywords.  \n- Nếu cụm chỉ có trong JD mà không có trong CV → KHÔNG được đưa vào matched_keywords.  \n- Mọi phán đoán phải dựa trên **nội dung có thật trong CV**.\n\n---\n\n🧩 BƯỚC 6 — QUY TẮC CUỐI\n\n- fit_score không được = 0 (trừ CV trống).  \n- Nếu cùng ngành khác vai trò → fit_score ≤ 60.  \n- Không cho fit_score = 100.  \n- role_match_type ∈ {\"Functional\", \"Technical\", \"Manager\"}.\n\n---\n\n📋 BƯỚC 7 — OUTPUT JSON\n\n{\n  \"candidate_name\": \"\",\n  \"fit_score\": 0,\n  \"jd_position\": \"\",\n  \"jd_domain\": \"\",\n  \"jd_function\": \"\",\n  \"cv_position\": \"\",\n  \"cv_domain\": \"\",\n  \"cv_function\": \"\",\n  \"domain_match\": false,\n  \"role_match_type\": \"\",\n  \"matched_keywords\": [],\n  \"strengths\": \"\",\n  \"weaknesses\": \"\",\n  \"recommendation\": \"\",\n  \"final_decision\": \"\",\n  \"cv_title\": \"\",\n  \"cv_roles\": [],\n  \"cv_years\": 0,\n  \"evidence\": []\n}\n\n---\n\n🧮 BƯỚC 8 — KẾT LUẬN SAU XỬ LÝ\n\n- Nếu không có CV nào fit_score ≥ 70 → “summary”: “Không có hồ sơ phù hợp. Đề xuất chọn CV khác.”  \n- Nếu có ≥1 CV đủ điều kiện → “summary”: “🏆 Ứng viên xuất sắc nhất: [Tên] ([Điểm]%)”.\n\n---\n\n🧠 MỤC TIÊU CUỐI  \nĐảm bảo mô hình hiểu và suy luận được:\n- ERP Manager ≠ Kế toán  \n- Functional ≠ Technical  \n- Nhân sự ≈ Hành chánh  \n- QA/QC ≈ Sản xuất  \n- JD không lan sang CV  \n- Phân loại nghề nghiệp chính xác theo ontology trên.\n"
        },
        "promptType": "define"
      },
      "typeVersion": 2.2
    },
    {
      "id": "b417a559-5741-410e-ad42-89d88cd63f31",
      "name": "解析招聘官输出",
      "type": "n8n-nodes-base.code",
      "position": [
        784,
        -128
      ],
      "parameters": {
        "jsCode": "// Parse Recruiter Output - Tie-breaker only (no bonus to fit_score)\ntry {\n  let raw = $json.output || $json;\n\n  // Nếu AI trả về {output:\"[...json...]\"} thì lấy phần bên trong\n  if (typeof raw === 'object' && raw.output) raw = raw.output;\n\n  // Làm sạch chuỗi JSON nếu cần\n  if (typeof raw === 'string') {\n    const match = raw.match(/\\[\\s*{[\\s\\S]*}\\s*\\]/);\n    if (match) raw = match[0];\n    raw = raw.trim().replace(/^[\\uFEFF\\x00-\\x1F]+/, '');\n  }\n\n  // Thử parse JSON (kể cả khi bị double-encode)\n  let parsed;\n  try {\n    parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;\n  } catch (innerErr) {\n    parsed = JSON.parse(JSON.parse(raw));\n  }\n\n  // Chuẩn hóa thành mảng\n  const arr = Array.isArray(parsed) ? parsed : [parsed];\n  if (!arr.length) throw new Error(\"Không có dữ liệu ứng viên hợp lệ\");\n\n  // Helpers\n  const norm = (s) => (s || '').toLowerCase().trim();\n  const domEq = (cv) => norm(cv.cv_domain) && norm(cv.jd_domain) && norm(cv.cv_domain) === norm(cv.jd_domain);\n  const domNear = (cv) => {\n    const a = norm(cv.cv_domain), b = norm(cv.jd_domain);\n    if (!a || !b) return false;\n    return a.includes(b) || b.includes(a);\n  };\n  const kwLen = (cv) => Array.isArray(cv.matched_keywords) ? cv.matched_keywords.length : 0;\n  const years = (cv) => Number(cv.cv_years || 0);\n\n  // Đồng bộ nhãn hiển thị và auto-fix domain_match nếu AI sai\n  const candidates = arr.map(cv => {\n    const c = { ...cv };\n    // Role match label = đúng domain CV (không “Other” cứng)\n    c.role_match_label = (c.cv_domain || '').trim() || 'Không xác định';\n\n    // Auto-fix: nếu domain bằng nhau mà domain_match = false thì sửa thành true\n    if (domEq(c) && c.domain_match === false) c.domain_match = true;\n\n    return c;\n  });\n\n  // Tổng và danh sách đạt chuẩn\n  const total = candidates.length;\n  const qualified = candidates.filter(cv => (cv.fit_score || 0) >= 70);\n\n  // Comparator chọn ứng viên tốt nhất:\n  // 1) fit_score cao hơn thắng\n  // 2) Nếu bằng nhau: domain = JD thắng\n  // 3) Nếu vẫn bằng nhau: domain gần JD thắng\n  // 4) Nếu vẫn bằng nhau: nhiều matched_keywords hơn thắng\n  // 5) Nếu vẫn bằng nhau: cv_years nhiều hơn thắng\n  const better = (a, b) => {\n    const fa = Number(a.fit_score || 0);\n    const fb = Number(b.fit_score || 0);\n    if (fa !== fb) return fa > fb ? a : b;\n\n    const aEq = domEq(a), bEq = domEq(b);\n    if (aEq !== bEq) return aEq ? a : b;\n\n    const aNear = domNear(a), bNear = domNear(b);\n    if (aNear !== bNear) return aNear ? a : b;\n\n    const aKw = kwLen(a), bKw = kwLen(b);\n    if (aKw !== bKw) return aKw > bKw ? a : b;\n\n    const aY = years(a), bY = years(b);\n    if (aY !== bY) return aY > bY ? a : b;\n\n    return a; // giữ nguyên thứ tự nếu vẫn hòa\n  };\n\n  const best = candidates.reduce((acc, cv) => acc ? better(acc, cv) : cv, null);\n\n  // Summary luôn dùng fit_score “gốc” (không cộng thưởng)\n  const summary_text =\n    best && (best.fit_score || 0) >= 70\n      ? `🏆 Ứng viên điểm đánh giá cao nhất: ${best.candidate_name || \"(Không xác định)\"} (${best.fit_score}%)`\n      : `Không có hồ sơ phù hợp. Đề xuất tìm thêm ứng viên khác.`;\n\n  return [{\n    json: {\n      total_candidates: total,\n      qualified_candidates: qualified.length,\n      best_candidate: best?.candidate_name || \"\",\n      best_score: best?.fit_score || 0,\n      summary_text,\n      candidates\n    }\n  }];\n\n} catch (err) {\n  return [{\n    json: {\n      error: \"JSON_PARSE_FAILED\",\n      message: err.message,\n      raw_snippet: String($json.output || '').slice(0, 500)\n    }\n  }];\n}\n"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "d87866a2-c309-45dc-9d6f-187eda5caac0",
      "name": "列出文件",
      "type": "n8n-nodes-base.code",
      "position": [
        -1584,
        16
      ],
      "parameters": {
        "jsCode": "const body = $json.body || {};\nconst jd = body.message || \"JD chưa xác định\";\nconst files = Array.isArray(body.files) ? body.files : [];\n\nreturn files.map((f, i) => ({\n  json: {\n    jd,\n    index: i + 1,\n    filename: f.name || `file_${i + 1}.pdf`,\n    base64: (f.data || \"\").split(\",\")[1] || \"\",\n  }\n}));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "32e65a79-c7e0-4f75-8f7b-72226f4f166e",
      "name": "检测 PDF 类型",
      "type": "n8n-nodes-base.code",
      "position": [
        -1360,
        16
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Detect PDF type: text-based or scanned image\n\n// Lấy thông tin cơ bản\nconst jd = $json.jd || \"JD chưa xác định\";\nconst index = $json.index || 1;\nconst filename = $json.filename || `cv_${index}.pdf`;\nconst base64 = $json.base64 || \"\";\n\n// Nếu không có base64 thì bỏ qua\nif (!base64) {\n  return {\n    json: {\n      jd,\n      index,\n      filename,\n      base64: null,\n      pdf_type: \"unknown\",\n      note: \"⚠️ Không có dữ liệu base64 để kiểm tra.\"\n    }\n  };\n}\n\ntry {\n  // Chuyển base64 → text để dò nội dung readable\n  const pdfBuffer = Buffer.from(base64, \"base64\");\n  const text = pdfBuffer.toString(\"utf8\");\n\n  // Regex kiểm tra xem có đoạn text đọc được hay không\n  const hasReadableText = /[A-Za-zÀ-ỹ0-9]{3,}/.test(text);\n  const pdf_type = hasReadableText ? \"text\" : \"scan\";\n\n  return {\n    json: {\n      jd,\n      index,\n      filename,\n      base64, // 👈 Giữ lại base64 để node sau còn dùng\n      pdf_type,\n      note: hasReadableText\n        ? \"✅ PDF có lớp text, đọc được.\"\n        : \"⚠️ PDF dạng ảnh, không có lớp text.\"\n    }\n  };\n\n} catch (err) {\n  return {\n    json: {\n      jd,\n      index,\n      filename,\n      base64, // vẫn giữ để debug khi lỗi\n      pdf_type: \"error\",\n      note: \"❌ Lỗi khi đọc base64: \" + err.message\n    }\n  };\n}\n"
      },
      "typeVersion": 2
    },
    {
      "id": "4c49c7f6-7657-4453-985c-bc378849a09e",
      "name": "条件判断",
      "type": "n8n-nodes-base.if",
      "position": [
        -1136,
        16
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "603e402b-0f8c-4c51-a550-49f4564ad0cb",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": "=={{ $json[\"pdf_type\"] === \"text\" }}"
      },
      "typeVersion": 2.2,
      "alwaysOutputData": true
    },
    {
      "id": "64cdfed3-cba0-490b-8119-e90a3273632b",
      "name": "OpenAI 聊天模型",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        504,
        96
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini",
          "cachedResultName": "gpt-4o-mini"
        },
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "id": "PzDD1MSeg2N7SDe0",
          "name": "OpenAi account 2"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "471ff4c6-bb44-45b8-bc9c-c8ef9e0aea45",
      "name": "从文件提取",
      "type": "n8n-nodes-base.extractFromFile",
      "position": [
        -464,
        -128
      ],
      "parameters": {
        "options": {},
        "operation": "pdf"
      },
      "executeOnce": false,
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "508b522e-4eee-4104-9703-f3f4097d4ac9",
      "name": "将 Base64 转换为二进制",
      "type": "n8n-nodes-base.code",
      "position": [
        -912,
        16
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Convert base64 → Binary cho từng CV\n\nconst base64 = $json.base64 || \"\";\nconst jd = $json.jd || \"JD chưa xác định\";\nconst index = $json.index || 1;\nconst filename = $json.filename || `cv_${index}.pdf`;\n\n// Nếu không có dữ liệu base64 thì bỏ qua\nif (!base64) {\n  return {\n    json: {\n      jd,\n      index,\n      filename,\n      note: \"⚠️ Không có dữ liệu base64 để convert.\"\n    }\n  };\n}\n\nreturn {\n  json: {\n    jd,\n    index,\n    filename\n  },\n  binary: {\n    data: {\n      data: Buffer.from(base64, \"base64\"),\n      mimeType: \"application/pdf\",\n      fileName: filename\n    }\n  }\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "e9e7a983-9130-4f12-bfc5-c0d4163172c3",
      "name": "遍历项目",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -688,
        16
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "1f286db8-2495-4359-9db0-7d0a60c61201",
      "name": "替换我",
      "type": "n8n-nodes-base.noOp",
      "position": [
        -464,
        64
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "4cbf8ff2-9884-46f9-b21d-207a880392f7",
      "name": "提取后重新附加元数据",
      "type": "n8n-nodes-base.code",
      "position": [
        -240,
        -128
      ],
      "parameters": {
        "jsCode": "// Node: Reattach_Metadata_After_Extract (bản nâng cấp full logic)\n// Tác dụng: Gắn lại jd, filename, text, và tự đọc candidate_name, candidate_position, role_match_type\n\nconst extracted = $input.all(); // Kết quả từ Extract From File\nconst srcItems = $items(\"Convert Base64 to Binary\", 0); // Dữ liệu gốc có jd, filename, index\n\n// ====== HÀM TIỆN ÍCH ======\nconst normalize = (s) => (s || \"\").replace(/\\s+/g, \" \").trim();\n\nconst cleanName = (name) => {\n  return name\n    .replace(/[^\\p{L}\\s]/gu, \"\")\n    .replace(/\\s+/g, \" \")\n    .trim()\n    .replace(/\\b(\\p{L})/gu, (m) => m.toUpperCase());\n};\n\nreturn extracted.map((ex, i) => {\n  const src = (srcItems[i] && srcItems[i].json) ? srcItems[i].json : {};\n  const text = ex.json?.text || \"\";\n  const lines = text.split(/\\n/).map(l => l.trim()).filter(Boolean);\n  const header = lines.slice(0, 5).join(\" \");\n\n  let candidateName = \"\";\n  let candidatePosition = \"\";\n  let roleMatchType = \"Other\";\n\n  // ====== 1️⃣ XÁC ĐỊNH TÊN ỨNG VIÊN ======\n  // Dạng đầy đủ: \"Nguyen Thi Ngoc Phuong\"\n  const fullNameRegex = /\\b([A-ZĐ][a-zàáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ]{1,}\\s){1,4}[A-ZĐ][a-zàáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ]{2,}/gu;\n  const matchName = header.match(fullNameRegex);\n  if (matchName) candidateName = cleanName(matchName[0]);\n\n  // Dạng viết tắt: N.T.K.Diễm, L.T.M.Trinh\n  if (!candidateName) {\n    const abbr = header.match(/[A-Z]\\.[A-Z]\\.[A-Z]\\.[A-Za-zÀ-ỹ]{2,}/);\n    if (abbr) candidateName = abbr[0].replace(/\\./g, \" \").trim();\n  }\n\n  // Tên nằm trong “Thông Tin Cá Nhân” hoặc “Họ và Tên”\n  if (!candidateName) {\n    const infoBlock = text.match(/(Họ\\s*và\\s*tên|Thông\\s*Tin\\s*Cá\\s*Nhân)[\\s\\S]{0,150}/i);\n    if (infoBlock) {\n      const found = infoBlock[0].match(fullNameRegex);\n      if (found) candidateName = cleanName(found[0]);\n    }\n  }\n\n  // ====== 2️⃣ XÁC ĐỊNH CHỨC DANH (VỊ TRÍ) ======\n  const roleRegex = /(Team\\s*Leader|Manager|Consultant|Specialist|Developer|Engineer|Officer|Supervisor|Coordinator|Executive|Director|Leader|Nhân\\s*viên\\s*[^\\n,;]+|Chuyên\\s*viên\\s*[^\\n,;]+|Trưởng\\s*[^\\n,;]+|Giám\\s*sát\\s*[^\\n,;]+)/i;\n  const matchRole = text.match(roleRegex);\n  if (matchRole) candidatePosition = normalize(matchRole[0]);\n\n  // ====== 3️⃣ PHÂN LOẠI role_match_type ======\n  if (/Manager|Director|Trưởng/i.test(candidatePosition)) roleMatchType = \"Manager\";\n  else if (/Consultant|Functional|Analyst|Specialist|Officer/i.test(candidatePosition)) roleMatchType = \"Functional\";\n  else if (/Developer|Technical|Engineer|Programmer|Basis/i.test(candidatePosition)) roleMatchType = \"Technical\";\n  else if (/Admin|Hành\\s*chánh|Nhân\\s*sự/i.test(candidatePosition)) roleMatchType = \"Admin\";\n  else roleMatchType = \"Other\";\n\n  // ====== 4️⃣ KẾT QUẢ ======\n  return {\n    json: {\n      jd: src.jd ?? null,\n      index: src.index ?? (i + 1),\n      filename: src.filename ?? ex?.binary?.data?.fileName ?? null,\n      text,\n      info: ex.json?.info ?? {},\n      candidate_name: candidateName || null,\n      candidate_position: candidatePosition || null,\n      role_match_type: roleMatchType\n    }\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "c664f25b-3f3c-446e-832c-8a7df09334b4",
      "name": "为 AI 合并候选人",
      "type": "n8n-nodes-base.code",
      "position": [
        -16,
        -128
      ],
      "parameters": {
        "jsCode": "// --- Combine_Candidates_For_AI ---\n// Gom tất cả các CV thành mảng candidates[] để AI Agent đọc\n// Đồng thời tự động lấy dòng tên đầu tiên trong CV (ví dụ: \"N.T.K.Diễm\", \"L.T.M.Trinh\")\n\nfunction extractRawName(text = \"\") {\n  if (!text) return \"\";\n\n  const lines = text\n    .replace(/\\r/g, \"\")\n    .replace(/\\t/g, \" \")\n    .split(\"\\n\")\n    .map(l => l.trim())\n    .filter(l => l && l.length > 1)\n    .slice(0, 8); // chỉ xem vài dòng đầu tiên\n\n  // Bỏ qua các dòng chứa cụm từ “Thông tin cá nhân”, “CV”, “Sơ yếu lý lịch”, “Curriculum Vitae”\n  const skip = /(thông tin|sơ yếu|curriculum vitae|cv|profile)/i;\n  const filtered = lines.filter(l => !skip.test(l));\n\n  // Lấy dòng đầu tiên hợp lệ (giữ nguyên format, không sửa gì)\n  return filtered.length ? filtered[0] : \"\";\n}\n\nreturn [\n  {\n    json: {\n      jd: $items(\"Reattach_Metadata_After_Extract\")[0].json.jd,\n      candidates: $items(\"Reattach_Metadata_After_Extract\").map(item => {\n        const text = item.json.text || \"\";\n        const nameFromText = extractRawName(text);\n\n        return {\n          filename: item.json.filename,\n          // Ưu tiên tên trích từ text, nếu rỗng mới lấy tên cũ\n          candidate_name: nameFromText || item.json.candidate_name || \"\",\n          candidate_position: item.json.candidate_position || \"\",\n          role_match_type: item.json.role_match_type || \"Other\",\n          text\n        };\n      })\n    }\n  }\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "f249f3d8-524e-4e46-97a9-5577fde7a46b",
      "name": "预处理简历名称",
      "type": "n8n-nodes-base.code",
      "position": [
        208,
        -128
      ],
      "parameters": {
        "jsCode": "// --- Node: Preprocess_CV_Names ---\n// Mục tiêu: lấy đúng dòng đầu tiên (tên thô trong CV), bỏ qua các dòng như \"Thông tin cá nhân\", \"CV\", \"Sơ yếu lý lịch\".\n// Không đổi hoa/thường, không bỏ dấu, không bỏ dấu chấm.\n\nfunction extractRawName(text = \"\") {\n  if (!text) return \"\";\n\n  const lines = text\n    .replace(/\\r/g, \"\")\n    .replace(/\\t/g, \" \")\n    .split(\"\\n\")\n    .map(l => l.trim())\n    .filter(l => l && l.length > 1)\n    .slice(0, 8); // chỉ quét vài dòng đầu CV\n\n  // Bỏ qua dòng chứa \"Thông tin cá nhân\", \"CV\", \"Curriculum Vitae\"...\n  const skip = /(thông tin|sơ yếu|curriculum vitae|cv|profile)/i;\n  const filtered = lines.filter(l => !skip.test(l));\n\n  // Lấy dòng đầu tiên còn lại (giữ nguyên xi)\n  return filtered.length ? filtered[0] : \"\";\n}\n\nreturn $input.all().map(item => {\n  const t = item.json.text || \"\";\n  const name = extractRawName(t);\n\n  // Debug xem đọc ra gì\n  console.log(\"📄 File:\", item.json.filename, \"→ Name:\", name);\n\n  return {\n    json: {\n      ...item.json,\n      // Ép cập nhật lại tên, KHÔNG fallback\n      candidate_name: name,\n    },\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "f8aff173-8caf-428a-8aae-e5ac8203edb5",
      "name": "便签",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1920,
        -368
      ],
      "parameters": {
        "height": 320,
        "content": "🟩 步骤 1:上传文件(职位描述 + 简历)"
      },
      "typeVersion": 1
    },
    {
      "id": "c3c11827-e7f5-4753-914a-cf1a312fc5e3",
      "name": "便签1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -784,
        -368
      ],
      "parameters": {
        "height": 304,
        "content": "🟨 步骤 2:提取与合并文本"
      },
      "typeVersion": 1
    },
    {
      "id": "7e966941-fecf-4a79-ac74-104abe8cf9c5",
      "name": "便签2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        416,
        -464
      ],
      "parameters": {
        "height": 288,
        "content": "🟦 步骤 3:AI 分析与输出"
      },
      "typeVersion": 1
    }
  ],
  "active": true,
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "95b2d7aa-89a3-4007-8960-a8f4dd2d158e",
  "connections": {
    "If": {
      "main": [
        [
          {
            "node": "Convert Base64 to Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "List_File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List_File": {
      "main": [
        [
          {
            "node": "Detect PDF Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Replace Me": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect PDF Type": {
      "main": [
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items": {
      "main": [
        [
          {
            "node": "Extract from File",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Replace Me",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract from File": {
      "main": [
        [
          {
            "node": "Reattach_Metadata_After_Extract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Recruiter Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "AI Recruiter Agent": {
      "main": [
        [
          {
            "node": "Parse Recruiter Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond to Webhook": {
      "main": [
        []
      ]
    },
    "Preprocess_CV_Names": {
      "main": [
        [
          {
            "node": "AI Recruiter Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Recruiter Output": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert Base64 to Binary": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine_Candidates_For_AI": {
      "main": [
        [
          {
            "node": "Preprocess_CV_Names",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reattach_Metadata_After_Extract": {
      "main": [
        [
          {
            "node": "Combine_Candidates_For_AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
常见问题

如何使用这个工作流?

复制上方的 JSON 配置代码,在您的 n8n 实例中创建新工作流并选择「从 JSON 导入」,粘贴配置后根据需要修改凭证设置即可。

这个工作流适合什么场景?

高级 - 人力资源, AI 摘要总结

需要付费吗?

本工作流完全免费,您可以直接导入使用。但请注意,工作流中使用的第三方服务(如 OpenAI API)可能需要您自行付费。

工作流信息
难度等级
高级
节点数量18
分类2
节点类型10
难度说明

适合高级用户,包含 16+ 个节点的复杂工作流

作者
Ms. Phuong Nguyen (phuongntn)

Ms. Phuong Nguyen (phuongntn)

@phuongntn

AI & Automation Developer at SCAVI Vietnam. Building intelligent HR and SAP workflows with n8n, OpenAI, and Supabase to transform business data into smart decisions.

外部链接
在 n8n.io 查看

分享此工作流