AI Recruiter - Analyse multiple de CV
Ceci est unHR, AI Summarizationworkflow d'automatisation du domainecontenant 18 nœuds.Utilise principalement des nœuds comme If, Code, Webhook, SplitInBatches, Agent. Utiliser OpenAI GPT pour analyser la correspondance entre plusieurs CV et des descriptions de postes
- •Point de terminaison HTTP Webhook (généré automatiquement par n8n)
- •Clé API OpenAI
Nœuds utilisés (18)
Catégorie
{
"id": "iDDUv4QXan1FQAx5",
"meta": {
"instanceId": "01ec604fa4293a8db3ec193f3cc15d3de221decf0ea6072f2f2ae0b8307f4988",
"templateCredsSetupCompleted": true
},
"name": "AI Recruiter – Multi-CV Analyzer",
"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": "Répondre à 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": "Agent Recruteur IA",
"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": "Analyser la Sortie du Recruteur",
"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": "Lister_Fichier",
"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": "Détecter le Type 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": "Si",
"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 Chat Model",
"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": "Extraire du Fichier",
"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": "Convertir Base64 en Binaire",
"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": "Boucler sur les Éléments",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-688,
16
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "1f286db8-2495-4359-9db0-7d0a60c61201",
"name": "Remplacer Moi",
"type": "n8n-nodes-base.noOp",
"position": [
-464,
64
],
"parameters": {},
"typeVersion": 1
},
{
"id": "4cbf8ff2-9884-46f9-b21d-207a880392f7",
"name": "Réattacher_Métadonnées_Après_Extraction",
"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": "Combiner_Candidats_Pour_IA",
"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": "Prétraiter_Noms_CV",
"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": "Note adhésive",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1920,
-368
],
"parameters": {
"height": 320,
"content": "🟩 Step 1: Upload Files (JD + CVs)\n- Webhook node receives one JD and multiple CV files.\n- List_File node extracts filenames and base64 data.\n- Detect PDF Type node determines whether each file is text-based or scanned.\n\n"
},
"typeVersion": 1
},
{
"id": "c3c11827-e7f5-4753-914a-cf1a312fc5e3",
"name": "Note adhésive1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-784,
-368
],
"parameters": {
"height": 304,
"content": "🟨 Step 2: Extract & Merge Text\n- Convert Base64 to Binary → prepares each CV for text extraction.\n- Extract From File → extracts readable text from PDF.\n- Reattach_Metadata_After_Extract → restores JD, filename, and candidate metadata.\n- Combine_Candidates_For_AI → aggregates all CVs into a single candidates[] array.\n"
},
"typeVersion": 1
},
{
"id": "7e966941-fecf-4a79-ac74-104abe8cf9c5",
"name": "Note adhésive2",
"type": "n8n-nodes-base.stickyNote",
"position": [
416,
-464
],
"parameters": {
"height": 288,
"content": "🟦 Step 3: AI Analysis & Output\n- AI Recruiter Agent compares JD vs. each CV and generates structured insights (fit_score, strengths, weaknesses, recommendation).\n- Parse Recruiter Output selects the top candidate and builds a summary.\n- Respond to Webhook returns the full JSON result to chat UI or external API.\n\n"
},
"typeVersion": 1
}
],
"active": true,
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"versionId": "95b2d7aa-89a3-4007-8960-a8f4dd2d158e",
"connections": {
"4c49c7f6-7657-4453-985c-bc378849a09e": {
"main": [
[
{
"node": "508b522e-4eee-4104-9703-f3f4097d4ac9",
"type": "main",
"index": 0
}
]
]
},
"613e9242-0f92-40a4-a31b-2437c4d71c78": {
"main": [
[
{
"node": "d87866a2-c309-45dc-9d6f-187eda5caac0",
"type": "main",
"index": 0
}
]
]
},
"d87866a2-c309-45dc-9d6f-187eda5caac0": {
"main": [
[
{
"node": "32e65a79-c7e0-4f75-8f7b-72226f4f166e",
"type": "main",
"index": 0
}
]
]
},
"1f286db8-2495-4359-9db0-7d0a60c61201": {
"main": [
[
{
"node": "e9e7a983-9130-4f12-bfc5-c0d4163172c3",
"type": "main",
"index": 0
}
]
]
},
"32e65a79-c7e0-4f75-8f7b-72226f4f166e": {
"main": [
[
{
"node": "4c49c7f6-7657-4453-985c-bc378849a09e",
"type": "main",
"index": 0
}
]
]
},
"e9e7a983-9130-4f12-bfc5-c0d4163172c3": {
"main": [
[
{
"node": "471ff4c6-bb44-45b8-bc9c-c8ef9e0aea45",
"type": "main",
"index": 0
}
],
[
{
"node": "1f286db8-2495-4359-9db0-7d0a60c61201",
"type": "main",
"index": 0
}
]
]
},
"471ff4c6-bb44-45b8-bc9c-c8ef9e0aea45": {
"main": [
[
{
"node": "4cbf8ff2-9884-46f9-b21d-207a880392f7",
"type": "main",
"index": 0
}
]
]
},
"64cdfed3-cba0-490b-8119-e90a3273632b": {
"ai_languageModel": [
[
{
"node": "6330221c-ec10-4c28-a05e-8a1c8640a3ad",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"6330221c-ec10-4c28-a05e-8a1c8640a3ad": {
"main": [
[
{
"node": "b417a559-5741-410e-ad42-89d88cd63f31",
"type": "main",
"index": 0
}
]
]
},
"7f7a7318-cb2f-4c47-bc6f-108e307f04d1": {
"main": [
[]
]
},
"f249f3d8-524e-4e46-97a9-5577fde7a46b": {
"main": [
[
{
"node": "6330221c-ec10-4c28-a05e-8a1c8640a3ad",
"type": "main",
"index": 0
}
]
]
},
"b417a559-5741-410e-ad42-89d88cd63f31": {
"main": [
[
{
"node": "7f7a7318-cb2f-4c47-bc6f-108e307f04d1",
"type": "main",
"index": 0
}
]
]
},
"508b522e-4eee-4104-9703-f3f4097d4ac9": {
"main": [
[
{
"node": "e9e7a983-9130-4f12-bfc5-c0d4163172c3",
"type": "main",
"index": 0
}
]
]
},
"c664f25b-3f3c-446e-832c-8a7df09334b4": {
"main": [
[
{
"node": "f249f3d8-524e-4e46-97a9-5577fde7a46b",
"type": "main",
"index": 0
}
]
]
},
"4cbf8ff2-9884-46f9-b21d-207a880392f7": {
"main": [
[
{
"node": "c664f25b-3f3c-446e-832c-8a7df09334b4",
"type": "main",
"index": 0
}
]
]
}
}
}Comment utiliser ce workflow ?
Copiez le code de configuration JSON ci-dessus, créez un nouveau workflow dans votre instance n8n et sélectionnez "Importer depuis le JSON", collez la configuration et modifiez les paramètres d'authentification selon vos besoins.
Dans quelles scénarios ce workflow est-il adapté ?
Avancé - Ressources Humaines, Résumé IA
Est-ce payant ?
Ce workflow est entièrement gratuit et peut être utilisé directement. Veuillez noter que les services tiers utilisés dans le workflow (comme l'API OpenAI) peuvent nécessiter un paiement de votre part.
Workflows recommandés
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.
Partager ce workflow