مشروع التخرج (2): بناء أداة تحليل السيرة الذاتية (CV Parser) ومقارنتها بمتطلبات الوظيفة
مشروع التخرج (2): بناء أداة تحليل السيرة الذاتية ومقارنتها بمتطلبات الوظيفة
في هذا المشروع سنبني نظاماً عملياً يجمع بين تحليل المستندات، استخراج البيانات المهيكلة، المطابقة الدلالية، والتفسير النصي الذكي. الفكرة الأساسية هي استقبال ملف سيرة ذاتية بصيغة PDF أو DOCX، ثم استخراج المهارات والخبرات والتعليم واللغات، وبعد ذلك مقارنتها مع وصف وظيفي لمعرفة نسبة التوافق والفجوات المطلوب سدّها. هذا النوع من التطبيقات مفيد في أنظمة HR Tech، وأدوات الفرز الأولي، ولوحات التوظيف الذكية.
هندسياً، المشروع ليس مجرد استدعاء LLM ليقرأ السيرة الذاتية. بل هو خط أنابيب Pipeline يبدأ من استخراج النص، ثم تنظيفه، ثم فرض بنية JSON على المخرجات بالاعتماد على تقنيات شبيهة بما شرحناه في مقال استخراج بيانات مهيكلة (Output Parsers): إجبار الذكاء الاصطناعي على الرد بصيغة JSON فقط، ثم حساب تشابه دلالي باستخدام التضمينات (Embeddings) بدلاً من المطابقة النصية السطحية.
المعمارية الهندسية للأداة
أفضل تصميم لهذا المشروع يتكون من أربع طبقات واضحة:
- طبقة الإدخال: رفع السيرة الذاتية والوصف الوظيفي من المستخدم.
- طبقة الاستخراج: تحويل الملف إلى نص خام باستخدام قارئ
PDF parserأوDOCX reader. - طبقة الفهم البنيوي: استخدام
LLMلاستخراج الحقول المهمة مثل المهارات، سنوات الخبرة، المشاريع، الشهادات، والمسمى الوظيفي الحالي. - طبقة المطابقة والتقييم: تحويل السيرة والوصف إلى
Embeddings، ثم حساب درجة التقارب وتوليد تفسير بشري للفجوات.
هذا الأسلوب أفضل من جعل النموذج يعطي حكماً مباشراً فقط، لأننا نفصل بين Extraction وScoring. هذا يسهل الاختبار، ويحسن الموثوقية، ويجعل الأداة قابلة للتوسع لاحقاً بربطها بواجهة Streamlit أو نشرها عبر FastAPI.
تدفق البيانات من الملف إلى القرار
1) استخراج النص من السيرة الذاتية
إذا كنت بنيت سابقاً مشروعاً يعتمد على ملفات PDF، فستلاحظ أن هذه الخطوة مشابهة لما استخدمناه في بناء الخطوة الأولى في RAG: استخراج النصوص من ملفات PDF برمجياً. الهدف هنا هو الحصول على نص نظيف قدر الإمكان، لأن جودة الاستخراج تؤثر مباشرة على جودة التحليل.
import fitz # PyMuPDF
def extract_text_from_pdf(file_path: str) -> str:
doc = fitz.open(file_path)
pages = []
for page in doc:
pages.append(page.get_text("text"))
return "\n".join(pages).strip()
بعد ذلك من المفيد تنفيذ مرحلة Normalization لإزالة الفراغات الزائدة، وتوحيد الرموز، ومعالجة الأحرف غير المقروءة الناتجة عن بعض ملفات السير الذاتية المصممة بصرياً.
2) تحويل النص الخام إلى بيانات مهيكلة
بدلاً من سؤال النموذج سؤالاً عاماً مثل: “ما الذي يحتويه هذا الـ CV؟”، نستخدم Prompt صارماً يفرض مخططاً محدداً. هذه الفكرة مرتبطة أيضاً بمقال هندسة الأوامر (Prompt Engineering) داخل الكود: قوالب النصوص المتغيرة (F-Strings).
أنت محلل سير ذاتية. استخرج المعلومات التالية فقط من النص وأعدها بصيغة JSON صحيحة:
الاسم، البريد الإلكتروني، رقم الهاتف، المدينة، المهارات التقنية، المهارات الناعمة، سنوات الخبرة، الشهادات، التعليم، المشاريع، اللغات، المسمى الوظيفي الحالي.
إذا كانت قيمة ما غير موجودة أعدها كقيمة فارغة، ولا تخمّن معلومات غير مذكورة صراحة.
from openai import OpenAI
import json
client = OpenAI()
def parse_cv_with_llm(cv_text: str) -> dict:
prompt = f"""
Extract structured data from the following CV text.
Return valid JSON only with these keys:
full_name, email, phone, city, technical_skills, soft_skills,
years_of_experience, certifications, education, projects,
languages, current_job_title.
CV TEXT:
{cv_text}
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0,
messages=[
{"role": "system", "content": "You extract structured resume data and return JSON only."},
{"role": "user", "content": prompt}
]
)
content = response.choices[0].message.content
return json.loads(content)
لاحظ هنا أننا نستخدم temperature=0 لتقليل التباين، وهو ما يتوافق مع مبادئ الضبط التي ناقشناها في التحكم في إبداع الذكاء الاصطناعي: فهم وإعداد معاملات Temperature و Top-K برمجياً.
3) تحليل الوصف الوظيفي بنفس المنهج
حتى تكون المقارنة عادلة، يجب ألا نعالج السيرة الذاتية وحدها. بل نقوم أيضاً بتحويل إعلان الوظيفة إلى بنية مهيكلة تحتوي على المهارات المطلوبة، المهارات المفضلة، عدد سنوات الخبرة، نوع الشهادات، والأدوات التقنية الأساسية. هذا يمنع الوقوع في تقييمات فضفاضة أو عشوائية.
def parse_job_description(job_text: str) -> dict:
prompt = f"""
Extract job requirements from the following job description.
Return valid JSON only with these keys:
job_title, required_skills, preferred_skills,
minimum_years_experience, required_education,
required_certifications, responsibilities, tools.
JOB DESCRIPTION:
{job_text}
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0,
messages=[
{"role": "system", "content": "You extract structured job requirements and return JSON only."},
{"role": "user", "content": prompt}
]
)
return json.loads(response.choices[0].message.content)
حساب المطابقة: لماذا لا تكفي المقارنة النصية؟
إذا قارنت كلمة PyTorch مع كلمة Deep Learning Frameworks نصياً، قد تبدوان مختلفتين، لكنهما متقاربتان دلالياً. هنا تظهر قوة Embeddings. يمكنك مراجعة الأساس النظري في ما هي التضمينات (Embeddings)؟ تحويل النصوص البشرية إلى أرقام (Vectors).
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
def semantic_similarity(text_a: str, text_b: str) -> float:
vec_a = embedding_model.encode([text_a])
vec_b = embedding_model.encode([text_b])
score = cosine_similarity(vec_a, vec_b)[0][0]
return float(score)
عملياً، نحسب التشابه بين:
- ملخص خبرات المرشح والمهام الأساسية للوظيفة.
- قائمة المهارات التقنية في السيرة وقائمة المهارات المطلوبة.
- سنوات الخبرة الحالية والحد الأدنى المطلوب.
ثم ندمج هذه القيم داخل Weighted Scoring Formula بدلاً من الاعتماد على مقياس واحد فقط.
def calculate_match_score(cv_data: dict, job_data: dict) -> dict:
cv_skills = " ".join(cv_data.get("technical_skills", []))
job_skills = " ".join(job_data.get("required_skills", []))
skills_score = semantic_similarity(cv_skills, job_skills)
cv_projects = " ".join(cv_data.get("projects", []))
responsibilities = " ".join(job_data.get("responsibilities", []))
experience_score = semantic_similarity(cv_projects, responsibilities)
cv_years = cv_data.get("years_of_experience", 0) or 0
job_years = job_data.get("minimum_years_experience", 0) or 0
years_score = min(cv_years / job_years, 1.0) if job_years else 1.0
final_score = (skills_score * 0.5) + (experience_score * 0.3) + (years_score * 0.2)
return {
"skills_score": round(skills_score, 3),
"experience_score": round(experience_score, 3),
"years_score": round(years_score, 3),
"final_score": round(final_score, 3)
}
توليد تفسير قابل للفهم للبشر
بعد حساب الدرجة لا نعرض رقماً مجرداً فقط، لأن المستخدم أو مسؤول التوظيف يحتاج إلى تفسير. هنا يأتي دور LLM مرة أخرى، لكن بصفته مولداً لتقرير نهائي، لا مستخرجاً أولياً.
حلّل الفجوة بين بيانات المرشح ومتطلبات الوظيفة. اذكر:
1) نقاط القوة،
2) المهارات الناقصة،
3) مدى مناسبة سنوات الخبرة،
4) توصيات عملية لتحسين السيرة الذاتية لهذه الوظيفة.
اجعل الإجابة مهنية، مختصرة، وبدون مجاملات.
هذا الفصل بين الاستخراج، المطابقة، والتفسير يقلل الهلوسة ويعطي نتائج أكثر قابلية للمراجعة. وإذا أردت توسيع المشروع لاحقاً إلى قاعدة فرص وظيفية كاملة، يمكنك إدخال أوصاف الوظائف في قواعد البيانات المتجهة (Vector Databases) واستخدام بحث دلالي لاختيار أفضل الوظائف المطابقة لكل مرشح، بنفس منطق RAG لكن في سياق التوظيف.
اعتبارات مهمة للجودة والدقة
- لا تعتمد على النص الخام فقط إذا كانت السيرة تحتوي على أعمدة أو جداول معقدة؛ اختبر عدة أنواع من الملفات.
- أنشئ طبقة
Validationللتأكد من أن البريد والهاتف والسنوات بصيغ منطقية. - احتفظ بالحقول غير المؤكدة كفارغة بدلاً من اختراع قيم، لأن ذلك أكثر مهنية وأفضل لأنظمة
ATS. - راقب تكلفة الاستدعاءات إذا كان النظام يستقبل عدداً كبيراً من الملفات، ويمكنك الاستفادة من منهجيات حساب التكلفة وإدارة الرموز (Tokens).
- إذا استخدمت السير الذاتية كمدخلات حساسة، فالحل المحلي عبر Ollama أو ربط LangChain مع Ollama قد يكون أفضل للخصوصية.
الخلاصة الهندسية للمشروع
هذا المشروع ممتاز كمشروع تخرج لأنه يدمج عدة مهارات حقيقية يحتاجها مهندس LLM Applications: استخراج نصوص، بناء Prompts صارمة، استخدام مخرجات JSON منظمة، حساب التشابه عبر Embeddings، ثم توليد تقرير نهائي مفهوم للمستخدم. والأهم أنه يترجم الذكاء التوليدي من فكرة استعراضية إلى نظام قرار عملي يمكن تطويره إلى منصة توظيف متكاملة، أو دمجه لاحقاً بواجهة مرئية، أو نشره كخدمة مستقلة قابلة للاستهلاك عبر API.
1 comment