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)可能需要您自行付费。
相关工作流推荐
在可视化参考库中探索n8n节点
在可视化参考库中探索n8n节点
If
Ftp
Set
+93
113 节点I versus AI
其他
[模板] AI宠物店 v8
🐶 AI宠物店助手 - 集成GPT-4o、Google日历和WhatsApp/Instagram/Facebook
If
N8n
Set
+38
244 节点Amanda Benks
销售
AI 代理餐厅 [模板]
🤖 WhatsApp、Instagram 和 Messenger 的 AI 餐厅助手
If
N8n
Set
+37
239 节点Amanda Benks
其他
使用GPT-4.1、Outlook和Mem.ai自动化Microsoft Teams会议分析
使用GPT-4.1、Outlook和Mem.ai自动化Microsoft Teams会议分析
If
Set
Code
+19
61 节点Wayne Simpson
人力资源
灵活新闻聚合器 - 多源集成、AI分析和可设置频道
多源新闻策展系统,集成Mistral AI分析、摘要和自定义频道
If
Set
Xml
+32
120 节点Hybroht
内容创作
第一轮 Telegram 和 LinkedIn 快速通道 AI 招聘助手
AI候选人筛选流程:LinkedIn到Telegram,集成Gemini与Apify
If
Set
Code
+15
55 节点Dean Pike
人力资源
工作流信息
难度等级
高级
节点数量18
分类2
节点类型10
作者
Ms. Phuong Nguyen (phuongntn)
@phuongntnAI & 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 查看 →
分享此工作流