diff --git a/app/auth.py b/app/auth.py index 6637162..ee8b8ca 100644 --- a/app/auth.py +++ b/app/auth.py @@ -12,7 +12,7 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/docs-login") def verify_password(plain_password: str, hashed_password: str): pre_hashed_password = hashlib.sha256(plain_password.encode()).hexdigest() diff --git a/app/routers/auth_router.py b/app/routers/auth_router.py index a33629e..26bfe3c 100644 --- a/app/routers/auth_router.py +++ b/app/routers/auth_router.py @@ -12,6 +12,7 @@ import os from pydantic import BaseModel import httpx +from fastapi.security import OAuth2PasswordRequestForm router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"]) GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") @@ -203,4 +204,19 @@ async def get_current_user_profile(email: str = Depends(get_current_user)): "email": user["email"], "full_name": user.get("full_name", "HR Manager"), "avatar": user.get("avatar", "") - } \ No newline at end of file + } + +@router.post("/docs-login", response_model=Token, include_in_schema=False) +async def swagger_login(form_data: OAuth2PasswordRequestForm = Depends()): + db = get_db() + + user = await db["hr_users"].find_one({"email": form_data.username}) + + if not user or not verify_password(form_data.password, user["hashed_password"]): + raise HTTPException(status_code=401, detail="Email hoặc mật khẩu không chính xác") + + if not user.get("is_verified", False): + raise HTTPException(status_code=401, detail="Vui lòng kiểm tra email để kích hoạt tài khoản!") + + access_token = create_access_token(data={"sub": user["email"]}) + return {"access_token": access_token, "token_type": "bearer"} \ No newline at end of file diff --git a/app/routers/cv_router.py b/app/routers/cv_router.py index 81244a0..47ac904 100644 --- a/app/routers/cv_router.py +++ b/app/routers/cv_router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form, Body from bson import ObjectId from datetime import datetime, timezone +from pydantic import BaseModel from app.database.config import get_db from app.database.models import CVUpdate @@ -8,25 +9,19 @@ from app.services.nlp_engine import extract_text, analyze_cv_text, score_cv from app.auth import get_current_user -router = APIRouter(prefix="/api/v1/cv", tags=["CV Processing"]) +router = APIRouter(prefix="/api/v1/cv", tags=["CV Processing & Talent Pool"]) MAX_FILE_SIZE = 5 * 1024 * 1024 +class MapCVRequest(BaseModel): + job_id: str + @router.post("/upload") -async def upload_and_score_cv( - job_id: str = Form(..., description="Mã chiến dịch tuyển dụng (Job ID)"), +async def upload_cv_to_pool( file: UploadFile = File(..., description="File CV định dạng PDF hoặc DOCX"), current_hr: str = Depends(get_current_user) ): db = get_db() - try: - jd_data = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) - except Exception: - raise HTTPException(status_code=400, detail="Mã Job ID không hợp lệ định dạng") - - if not jd_data: - raise HTTPException(status_code=404, detail="Không tìm thấy chiến dịch tuyển dụng này") - content = await file.read() if len(content) > MAX_FILE_SIZE: raise HTTPException(status_code=400, detail="Dung lượng file vượt quá 5MB giới hạn") @@ -37,36 +32,28 @@ async def upload_and_score_cv( raise HTTPException(status_code=400, detail=f"Hệ thống không thể đọc được file này: {str(e)}") if not raw_text.strip(): - raise HTTPException(status_code=400, detail="Không thể trích xuất văn bản. File có thể là ảnh quét.") + raise HTTPException(status_code=400, detail="Không thể trích xuất văn bản.") cv_data = analyze_cv_text(raw_text) - cv_data["raw_text"] = raw_text - cv_data["filename"] = file.filename - candidate_email = cv_data.get("email") if candidate_email: existing_cv = await db["hr_cvs"].find_one({ - "job_id": job_id, + "hr_email": current_hr, "candidate_info.email": candidate_email }) if existing_cv: - raise HTTPException(status_code=400, detail=f"Ứng viên có email {candidate_email} đã được tải lên trong chiến dịch này rồi!") + return { + "message": "CV đã tồn tại trong Kho hồ sơ", + "cv_id": str(existing_cv["_id"]), + "candidate_email": candidate_email, + "is_existing": True + } - else: - existing_cv_by_name = await db["hr_cvs"].find_one({ - "job_id": job_id, - "filename": file.filename - }) - if existing_cv_by_name: - raise HTTPException(status_code=400, detail=f"Hồ sơ '{file.filename}' dường như đã tồn tại trong chiến dịch này (Không tìm thấy email để xác thực thêm).") - - scoring_result = score_cv(cv_data, jd_data) - - applicant_record = { - "job_id": job_id, + pool_record = { "hr_email": current_hr, "filename": file.filename, + "raw_text": raw_text, "candidate_info": { "email": cv_data.get("email"), "phone": cv_data.get("phone"), @@ -78,31 +65,83 @@ async def upload_and_score_cv( "years_of_experience": cv_data.get("years_of_experience", 0) }, "extracted_skills": cv_data.get("skills", []), - "ai_score": scoring_result, - "status": "Mới", - "note": "", "created_at": datetime.now(timezone.utc) } - result = await db["hr_cvs"].insert_one(applicant_record) + result = await db["hr_cvs"].insert_one(pool_record) return { - "message": "Phân tích và chấm điểm CV thành công", + "message": "Tải CV lên Kho hồ sơ (Talent Pool) thành công", "cv_id": str(result.inserted_id), "candidate_email": candidate_email, - "ai_score": scoring_result + "is_existing": False } + +@router.post("/{cv_id}/map") +async def map_cv_to_job( + cv_id: str, + payload: MapCVRequest, + current_hr: str = Depends(get_current_user) +): + db = get_db() + job_id = payload.job_id -@router.patch("/{cv_id}") -async def update_cv_status_and_notes( - cv_id: str, + try: + jd_data = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) + except: + raise HTTPException(status_code=400, detail="Mã Job ID không hợp lệ") + + if not jd_data: + raise HTTPException(status_code=404, detail="Không tìm thấy chiến dịch tuyển dụng") + + cv_record = await db["hr_cvs"].find_one({"_id": ObjectId(cv_id), "hr_email": current_hr}) + if not cv_record: + raise HTTPException(status_code=404, detail="Không tìm thấy CV trong Kho hồ sơ") + + existing_app = await db["hr_applications"].find_one({ + "cv_id": cv_id, + "job_id": job_id + }) + if existing_app: + raise HTTPException(status_code=400, detail="Hồ sơ này đã được đưa vào chiến dịch này rồi!") + + cv_data_for_scoring = { + "raw_text": cv_record.get("raw_text", ""), + "skills": cv_record.get("extracted_skills", []), + "years_of_experience": cv_record["candidate_info"].get("years_of_experience", 0), + "skill_experience": cv_record["candidate_info"].get("skill_experience", {}), + "education_level": cv_record["candidate_info"].get("education_level", "Không đề cập") + } + + scoring_result = score_cv(cv_data_for_scoring, jd_data) + + application_record = { + "hr_email": current_hr, + "job_id": job_id, + "cv_id": cv_id, + "ai_score": scoring_result, + "status": "Mới", + "note": "", + "applied_at": datetime.now(timezone.utc) + } + + result = await db["hr_applications"].insert_one(application_record) + + return { + "message": "Ghép nối CV vào chiến dịch và chấm điểm thành công", + "application_id": str(result.inserted_id), + "ai_score": scoring_result + } + +@router.patch("/applications/{app_id}") +async def update_application_status( + app_id: str, update_data: CVUpdate = Body(...), current_hr: str = Depends(get_current_user) ): db = get_db() - try: - filter_query = {"_id": ObjectId(cv_id), "hr_email": current_hr} + filter_query = {"_id": ObjectId(app_id), "hr_email": current_hr} update_query = {"$set": {}} if update_data.status is not None: @@ -114,41 +153,34 @@ async def update_cv_status_and_notes( if not update_query["$set"]: return {"message": "Không có dữ liệu mới nào để cập nhật"} - result = await db["hr_cvs"].update_one(filter_query, update_query) + result = await db["hr_applications"].update_one(filter_query, update_query) if result.matched_count == 0: - raise HTTPException(status_code=404, detail="Không tìm thấy CV này hoặc bạn không có quyền thao tác") - - return { - "status": "success", - "message": "Đã cập nhật hồ sơ ứng viên thành công" - } + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ ứng tuyển này") + return {"status": "success", "message": "Đã cập nhật trạng thái ứng viên thành công"} except Exception as e: - raise HTTPException(status_code=500, detail=f"Lỗi khi cập nhật CV: {str(e)}") - + raise HTTPException(status_code=500, detail=str(e)) + @router.delete("/{cv_id}") -async def delete_cv( +async def delete_cv_from_pool( cv_id: str, current_hr: str = Depends(get_current_user) ): db = get_db() try: - result = await db["hr_cvs"].delete_one({ - "_id": ObjectId(cv_id), - "hr_email": current_hr - }) - + result = await db["hr_cvs"].delete_one({"_id": ObjectId(cv_id), "hr_email": current_hr}) if result.deleted_count == 0: - raise HTTPException(status_code=404, detail="Không tìm thấy CV hoặc bạn không có quyền xóa!") - - return {"status": "success", "message": "Đã xóa hồ sơ ứng viên vĩnh viễn"} + raise HTTPException(status_code=404, detail="Không tìm thấy CV trong kho") + await db["hr_applications"].delete_many({"cv_id": cv_id}) + + return {"status": "success", "message": "Đã xóa vĩnh viễn CV khỏi hệ thống"} except Exception as e: - raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {str(e)}") - -@router.get("/all") -async def get_all_cvs(current_hr: str = Depends(get_current_user)): + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/pool") +async def get_talent_pool(current_hr: str = Depends(get_current_user)): db = get_db() try: cursor = db["hr_cvs"].find({"hr_email": current_hr}).sort("created_at", -1) @@ -157,16 +189,9 @@ async def get_all_cvs(current_hr: str = Depends(get_current_user)): for cv in cvs: cv["id"] = str(cv["_id"]) del cv["_id"] - - job = await db["hr_jobs"].find_one({"_id": ObjectId(cv["job_id"])}) - if job: - cv["job_title"] = job["title"] - cv["job_deadline"] = job.get("deadline") - else: - cv["job_title"] = "Chiến dịch đã xóa" - cv["job_deadline"] = None - + if "raw_text" in cv: + del cv["raw_text"] + return cvs - except Exception as e: - raise HTTPException(status_code=500, detail=f"Lỗi khi tải kho hồ sơ: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/app/routers/job_router.py b/app/routers/job_router.py index d9fb762..219c058 100644 --- a/app/routers/job_router.py +++ b/app/routers/job_router.py @@ -1,19 +1,46 @@ -from fastapi import APIRouter, Depends, HTTPException, Body +from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks from typing import List from datetime import datetime, timezone from bson import ObjectId + from app.auth import get_current_user from app.database.config import get_db from app.database.models import JobCreateEnterprise, JobResponse +from app.services.nlp_engine import score_cv router = APIRouter(prefix="/api/v1/jobs", tags=["Job Management & Ranking"]) +async def rescore_all_applications_for_job(job_id: str, jd_data: dict, current_hr: str): + db = get_db() + cursor = db["hr_applications"].find({"job_id": job_id}) + applications = await cursor.to_list(length=None) + + for app in applications: + cv_id = app["cv_id"] + cv_record = await db["hr_cvs"].find_one({"_id": ObjectId(cv_id)}) + if not cv_record: + continue + + cv_data_for_scoring = { + "raw_text": cv_record.get("raw_text", ""), + "skills": cv_record.get("extracted_skills", []), + "years_of_experience": cv_record["candidate_info"].get("years_of_experience", 0), + "skill_experience": cv_record["candidate_info"].get("skill_experience", {}), + "education_level": cv_record["candidate_info"].get("education_level", "Không đề cập") + } + + new_score = score_cv(cv_data_for_scoring, jd_data) + + await db["hr_applications"].update_one( + {"_id": app["_id"]}, + {"$set": {"ai_score": new_score}} + ) + print(f"Background Task Hoàn tất: Đã chấm lại {len(applications)} CV cho Job {job_id}") + @router.post("/") async def create_job(job: JobCreateEnterprise, current_hr: str = Depends(get_current_user)): db = get_db() - job_dict = job.model_dump() - jd_search_text = f"{job.description} {job.requirements} {job.benefits or ''} {job.other_info or ''}".lower() job_dict.update({ @@ -24,119 +51,110 @@ async def create_job(job: JobCreateEnterprise, current_hr: str = Depends(get_cur }) result = await db["hr_jobs"].insert_one(job_dict) - - return { - "message": "Tạo chiến dịch thành công", - "job_id": str(result.inserted_id) - } + return {"message": "Tạo chiến dịch thành công", "job_id": str(result.inserted_id)} @router.get("/", response_model=List[JobResponse]) async def get_my_jobs(current_hr: str = Depends(get_current_user)): db = get_db() cursor = db["hr_jobs"].find({"created_by": current_hr}).sort("created_at", -1) jobs = await cursor.to_list(length=100) - for job in jobs: job["id"] = str(job["_id"]) - return jobs @router.get("/{job_id}", response_model=JobResponse) async def get_job_detail(job_id: str, current_hr: str = Depends(get_current_user)): db = get_db() job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) - if not job: raise HTTPException(status_code=404, detail="Không tìm thấy chiến dịch") - job["id"] = str(job["_id"]) return job @router.put("/{job_id}") async def update_job( job_id: str, + background_tasks: BackgroundTasks, job_update: JobCreateEnterprise = Body(...), current_hr: str = Depends(get_current_user) ): db = get_db() - existing_job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) if not existing_job: raise HTTPException(status_code=404, detail="Không tìm thấy Job hoặc bạn không có quyền chỉnh sửa") update_data = job_update.model_dump() - jd_search_text = f"{job_update.description} {job_update.requirements} {job_update.benefits or ''} {job_update.other_info or ''}".lower() - - update_data.update({ - "updated_at": datetime.now(timezone.utc), - "jd_search_text": jd_search_text - }) + update_data.update({"updated_at": datetime.now(timezone.utc), "jd_search_text": jd_search_text}) - await db["hr_jobs"].update_one( - {"_id": ObjectId(job_id)}, - {"$set": update_data} - ) + await db["hr_jobs"].update_one({"_id": ObjectId(job_id)}, {"$set": update_data}) - return {"status": "success", "message": "Cập nhật chiến dịch thành công"} + background_tasks.add_task(rescore_all_applications_for_job, job_id, update_data, current_hr) + + return { + "status": "success", + "message": "Cập nhật JD thành công. Hệ thống đang tự động chấm lại điểm ứng viên ở chế độ chạy ngầm." + } @router.delete("/{job_id}") async def delete_job(job_id: str, current_hr: str = Depends(get_current_user)): db = get_db() - result = await db["hr_jobs"].delete_one({"_id": ObjectId(job_id), "created_by": current_hr}) - if result.deleted_count == 0: raise HTTPException(status_code=404, detail="Không tìm thấy Job hoặc bạn không có quyền xóa") - await db["hr_cvs"].delete_many({"job_id": job_id}) - - return {"status": "success", "message": "Đã xóa chiến dịch và toàn bộ CV liên quan"} + await db["hr_applications"].delete_many({"job_id": job_id}) + return {"status": "success", "message": "Đã xóa chiến dịch. CV ứng viên vẫn được bảo lưu trong Kho hồ sơ."} @router.get("/{job_id}/ranking") async def get_job_ranking(job_id: str, current_hr: str = Depends(get_current_user)): db = get_db() - job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) if not job: raise HTTPException(status_code=404, detail="Không tìm thấy chiến dịch") - cursor = db["hr_cvs"].find({"job_id": job_id}).sort("ai_score.total_score", -1) - cvs = await cursor.to_list(length=200) + cursor = db["hr_applications"].find({"job_id": job_id}).sort("ai_score.total_score", -1) + applications = await cursor.to_list(length=200) - for cv in cvs: - cv["id"] = str(cv["_id"]) - del cv["_id"] + leaderboard = [] + for app in applications: + app["id"] = str(app["_id"]) + del app["_id"] + + cv_record = await db["hr_cvs"].find_one({"_id": ObjectId(app["cv_id"])}) + if cv_record: + app["candidate_info"] = cv_record.get("candidate_info", {}) + app["filename"] = cv_record.get("filename", "") + app["extracted_skills"] = cv_record.get("extracted_skills", []) + + leaderboard.append(app) job["id"] = str(job["_id"]) del job["_id"] return { "job_info": job, - "total_candidates": len(cvs), - "leaderboard": cvs + "total_candidates": len(leaderboard), + "leaderboard": leaderboard } @router.get("/dashboard/analytics") async def get_dashboard_analytics(current_hr: str = Depends(get_current_user)): db = get_db() - total_jobs = await db["hr_jobs"].count_documents({"created_by": current_hr}) open_jobs = await db["hr_jobs"].count_documents({"created_by": current_hr, "status": "open"}) - - total_cvs = await db["hr_cvs"].count_documents({"hr_email": current_hr}) + total_cvs_in_pool = await db["hr_cvs"].count_documents({"hr_email": current_hr}) pipeline = [ {"$match": {"hr_email": current_hr}}, {"$group": {"_id": "$status", "count": {"$sum": 1}}} ] - status_counts = await db["hr_cvs"].aggregate(pipeline).to_list(length=None) - + status_counts = await db["hr_applications"].aggregate(pipeline).to_list(length=None) status_breakdown = {item["_id"] if item["_id"] else "Mới": item["count"] for item in status_counts} return { "total_jobs": total_jobs, "open_jobs": open_jobs, - "total_cvs": total_cvs, + "total_cvs_in_pool": total_cvs_in_pool, "status_breakdown": status_breakdown } \ No newline at end of file diff --git a/app/services/nlp_engine.py b/app/services/nlp_engine.py index 4527c89..36bcc37 100644 --- a/app/services/nlp_engine.py +++ b/app/services/nlp_engine.py @@ -224,7 +224,7 @@ def get_normalized_skill(raw_skill: str) -> str: return root return raw_lower -def calculate_skill_score(cv_skills: set, jd_required: list, jd_preferred: list): +def calculate_skill_score(cv_skills: set, cv_skill_exp: dict, jd_required: list, jd_preferred: list): score = 0.0 total_weight = sum(s.get('weight', 1.0) for s in jd_required) + sum(s.get('weight', 0.5) for s in jd_preferred) @@ -234,28 +234,46 @@ def calculate_skill_score(cv_skills: set, jd_required: list, jd_preferred: list) matched_skills = [] missing_required_skills = [] - for req in jd_required: - raw_name = req.get('name', '') - weight = req.get('weight', 1.0) + def evaluate_skill(skill_dict, default_weight): + raw_name = skill_dict.get('name', '') + weight = skill_dict.get('weight', default_weight) + req_years = skill_dict.get('min_years', 0) norm_name = get_normalized_skill(raw_name) if norm_name in cv_skills: - score += weight + cv_years = cv_skill_exp.get(norm_name, 0.0) + + if req_years > 0: + if cv_years >= req_years: + bonus = min((cv_years - req_years) * 0.1, 0.2) * weight + earned = weight + bonus + else: + ratio = cv_years / req_years + earned = weight * (0.5 + 0.5 * ratio) + else: + earned = weight + + return earned, raw_name, True + + return 0.0, raw_name, False + + for req in jd_required: + earned, raw_name, is_matched = evaluate_skill(req, 1.0) + if is_matched: + score += earned matched_skills.append(raw_name) else: missing_required_skills.append(raw_name) for pref in jd_preferred: - raw_name = pref.get('name', '') - weight = pref.get('weight', 0.5) - norm_name = get_normalized_skill(raw_name) - - if norm_name in cv_skills: - score += weight + earned, raw_name, is_matched = evaluate_skill(pref, 0.5) + if is_matched: + score += earned matched_skills.append(raw_name) final_score = (score / total_weight) * 100 if total_weight > 0 else 0 + final_score = min(final_score, 110.0) return round(final_score, 2), matched_skills, missing_required_skills @@ -324,10 +342,11 @@ def score_cv(cv_data: dict, jd_data: dict) -> dict: cv_text = cv_data.get("raw_text", "") cv_skills = set(cv_data.get("skills", [])) + cv_skill_exp = cv_data.get("skill_experience", {}) cv_yoe = cv_data.get("years_of_experience", 0) cv_edu = cv_data.get("education_level", "Không đề cập") - skill_score, matched_skills, missing_required_skills = calculate_skill_score(cv_skills, jd_required_skills, jd_preferred_skills) + skill_score, matched_skills, missing_required_skills = calculate_skill_score(cv_skills, cv_skill_exp, jd_required_skills, jd_preferred_skills) experience_score = calculate_experience_score(cv_yoe, jd_min_yoe) education_score = calculate_education_score(cv_edu, jd_min_edu) nlp_score = calculate_nlp_similarity(cv_text, jd_search_text)