Chấm điểm câu trả lời và tạo báo cáo phỏng vấn bằng AI
Hoàn thiện ứng dụng phỏng vấn AI: chấm điểm từng câu trả lời, tạo báo cáo cuối bằng AI agent và theo dõi đến khi báo cáo sẵn sàng.
Long Nguyen
Lập trình viên Fullstack · Kỹ sư AI · Nhà nghiên cứu
Khép lại quy trình
Đến phần 8, ứng viên đã có thể trả lời mọi câu hỏi bằng giọng nói. Nhưng một buổi phỏng vấn không có kết luận thì chưa phải là luyện tập — đó chỉ là nói chuyện. Phần này khép lại quy trình: chúng ta chấm điểm từng câu trả lời và tạo báo cáo phỏng vấn bằng AI, để ứng viên biết mình đã thể hiện thế nào và cần cải thiện ở đâu.
Cấu trúc này tương tự pipeline tạo câu hỏi ở phần trước: đây là công việc mất thời gian — một lần gọi AI cho mỗi câu trả lời, cộng thêm một lần gọi để tổng hợp cuối cùng — nên nó được chạy dưới dạng tác vụ nền, còn frontend sẽ polling cho đến khi báo cáo sẵn sàng. Vẫn là mô hình cũ, nhưng áp dụng cho một job mới.
Kích hoạt việc tạo báo cáo
Báo cáo chỉ được tạo khi mọi câu hỏi đã thực sự có câu trả lời — chấm điểm một buổi phỏng vấn còn dang dở sẽ cho kết quả sai lệch. Vì vậy, endpoint kích hoạt phải kiểm tra điều kiện này trước khi làm bất cứ việc gì:
class GenerateInterviewReportAPIView(APIView):
def post(self, request, *args, **kwargs):
interview = Interview.objects.get(
interview_uuid=kwargs["interview_id"],
device=request.device,
)
# only start if the interview is finished AND no question is still unanswered
all_answered = not InterviewQuestion.objects.filter(
interview=interview, status=1,
).exists()
if interview.status != 5 or not all_answered:
return Response(
{"err": "Please submit all questions to complete this interview!"},
status=400,
)
interview.status = 6 # generating report
interview.save(update_fields=["status"])
task = BackgroundTask.objects.create(
type="report_interview",
payload={"interview_id": interview.id},
name=f"Report Interview {interview.interview_uuid}",
)
start_background_task(task.id)
return Response({"msg": "success"}, status=200)
Ở đây có hai lớp kiểm tra phối hợp với nhau: buổi phỏng vấn phải ở trạng thái "started", đồng thời một truy vấn phải xác nhận không còn câu hỏi nào đang chờ trả lời (status=1). Chỉ sau đó hệ thống mới chuyển buổi phỏng vấn sang trạng thái "generating report" và giao việc cho hệ thống background task giống như ở phần 7. Tái sử dụng hệ thống này chính là lợi ích của việc xây dựng nó bài bản ngay từ đầu — thêm một job bất đồng bộ mới chỉ tốn vài dòng mã.
Chấm điểm rồi tổng hợp
Bên trong background task, công việc diễn ra qua hai giai đoạn và thứ tự này rất quan trọng. Trước tiên, từng câu hỏi đã có câu trả lời được chấm riêng bằng scoring_question_agent — câu trả lời của ứng viên được đưa vào, sau đó điểm số và phần giải thích được trả về dưới dạng structured output (cách tiếp cận đã dùng ở phần 5). Khi mọi câu trả lời đều đã có điểm, interview_report_agent sẽ xem xét toàn bộ kết quả và viết ra kết luận cuối cùng:
@register_task("report_interview")
def report_interview(task, payload):
interview = Interview.objects.get(id=payload["interview_id"])
openai_service = OpenAIService()
# 1. score each answered question on its own
questions = InterviewQuestion.objects.filter(interview=interview)
for q in questions:
prompt = (
f"Question: {q.question}\n"
f"Candidate answer: {q.user_answer}"
)
_, result = openai_service.run_agent(
prompt, scoring_question_agent, InterviewQuestionResultResponse
)
q.user_score = result.score
q.reason_score = result.reason
q.save(update_fields=["user_score", "reason_score", "updated_at"])
# 2. summarize the whole interview into one report
summary_input = "\n\n".join(
f"Q: {q.question}\nScore: {q.user_score}/10\nWhy: {q.reason_score}"
for q in questions
)
_, report = openai_service.run_agent(
summary_input, interview_report_agent, InterviewReportResponse
)
InterviewReport.objects.create(
interview=interview,
content=report.summary,
is_passed=report.is_passed,
)
interview.status = 7 # completed
interview.save(update_fields=["status", "updated_at"])
Việc tách thành "chấm điểm từng phần, rồi tổng hợp toàn bộ" là có chủ đích. Nếu yêu cầu một prompt vừa chấm tất cả câu trả lời vừa viết một kết luận mạch lạc, kết quả thường sẽ mơ hồ và thiếu nhất quán. Chấm riêng từng câu giúp mỗi đánh giá tập trung hơn; sau đó, bước tổng hợp sẽ lập luận dựa trên điểm số sạch của từng câu hỏi — gồm cả con số và phần giải thích — thay vì dựa trên transcript thô. Đây cũng là nguyên tắc ghép các bước nhỏ như trong pipeline tạo câu hỏi.
Có một chi tiết đáng chú ý: schema chấm điểm trả về phần giải thích trước con số. Thứ tự này là có chủ ý — yêu cầu agent giải thích lập luận trước rồi mới đưa ra điểm số giúp kết quả chấm nhất quán hơn so với việc yêu cầu một con số đơn lẻ ngay từ đầu. Nội dung prompt thực sự quy định cách chấm công bằng, duy trì tính nhất quán giữa những câu trả lời rất khác nhau và chống lại việc ứng viên cố nói để đạt điểm tuyệt đối mới là phần khó, đồng thời cũng là phần định hình sản phẩm. Những prompt đã được tinh chỉnh này có trong starter kit, thay vì là thứ bạn nên sao chép và dán nguyên xi.
Polling rồi lấy báo cáo
Vì báo cáo được chạy dưới nền, frontend không thể nhận kết quả chỉ trong một request. Nó sẽ polling đến endpoint báo cáo; endpoint trả về 202 Accepted khi công việc vẫn đang chạy và trả về báo cáo hoàn chỉnh khi xử lý xong:
class InterviewReportAPIView(APIView):
def get(self, request, *args, **kwargs):
interview = Interview.objects.get(
interview_uuid=kwargs["interview_id"],
device=request.device,
)
if interview.status != 7: # not completed yet
return Response({"msg": "Pending"}, status=202)
report = InterviewReport.objects.get(interview=interview)
return Response({
"data": {
"interview_report_content": report.content,
"is_passed": report.is_passed,
},
}, status=200)
Mã 202 tiếp tục thể hiện đúng vai trò như ở phần 7: "đã tiếp nhận, vẫn đang xử lý, hãy kiểm tra lại sau". Frontend polling vài giây một lần; ngay khi buổi phỏng vấn chuyển sang trạng thái hoàn tất (status=7), chính endpoint đó sẽ trả về nội dung báo cáo cùng kết quả đạt hoặc không đạt. Một endpoint, hai ý nghĩa, hoàn toàn do trạng thái quyết định — dễ xây dựng và cũng dễ sử dụng.
Lưu ý về việc tin tưởng dữ liệu đầu vào
Một nguyên tắc xuyên suốt pipeline này là: không bao giờ mặc nhiên tin dữ liệu do người dùng cung cấp. Nội dung CV được xem là dữ liệu không đáng tin cậy trước khi AI đọc; các câu trả lời cũng được xác thực trước khi lưu. Nguyên tắc tương tự áp dụng cho chính những file được tải lên — một file tự nhận là PDF hay đoạn audio phải được xác minh bằng nội dung thực tế, không phải tên file. Đây là một chủ đề riêng đáng tìm hiểu kỹ, và tôi đã trình bày trong bài Xác thực file tải lên trong Python.
Ứng dụng đã hoàn thiện
Đó là toàn bộ quy trình. Ứng viên tải CV lên, AI xây dựng một buổi phỏng vấn phù hợp, ứng viên trả lời bằng giọng nói và giờ đây nhận được báo cáo có chấm điểm cho biết mình đã thể hiện ra sao. Mọi thành phần cốt lõi của sản phẩm — phân tích dữ liệu, pipeline agent, xử lý nền, giọng nói, rồi chấm điểm và báo cáo — đều đã hoàn thiện và hoạt động xuyên suốt từ đầu đến cuối.
Phần còn lại không phải là thêm tính năng, mà là đưa ứng dụng lên internet một cách ổn định và đáng tin cậy. Nếu muốn đi thẳng đến mã nguồn hoàn chỉnh, sẵn sàng cho môi trường production và đã bao gồm các prompt chấm điểm, báo cáo được tinh chỉnh, tất cả đều có trong starter kit. Nếu không, phần 10 sẽ là phần tiếp theo và cũng là phần cuối: triển khai ứng dụng lên production và theo dõi hoạt động khi người dùng thực bắt đầu truy cập.
CÂU HỎI THƯỜNG GẶP
Câu hỏi thường gặp
Vì sao nên chấm điểm từng câu trả lời riêng thay vì chấm tất cả cùng lúc?
Nếu dùng một prompt để vừa chấm mọi câu trả lời vừa viết kết luận cuối cùng, kết quả thường sẽ mơ hồ và thiếu nhất quán. Chấm riêng từng câu giúp mỗi đánh giá tập trung hơn, sau đó bước tổng hợp cuối sẽ lập luận dựa trên điểm số của từng câu hỏi thay vì transcript thô.
Vì sao schema chấm điểm trả về phần giải thích trước điểm số?
Việc yêu cầu agent giải thích lập luận trước rồi mới đưa ra con số giúp kết quả chấm nhất quán hơn so với việc yêu cầu một điểm số đơn lẻ ngay từ đầu. Đây là một thay đổi nhỏ về thứ tự trong structured output nhưng có thể cải thiện đáng kể chất lượng.
Vì sao việc tạo báo cáo được chạy dưới dạng background task?
Đây là công việc mất thời gian — một lần gọi AI cho mỗi câu trả lời, cộng thêm một lần gọi để tổng hợp, có thể mất nhiều giây. Chạy ngoài vòng đời của request bằng cùng hệ thống background task ở phần 7 giúp ứng dụng vẫn phản hồi nhanh trong khi frontend polling để chờ kết quả.
Vì sao endpoint báo cáo trả về 202 khi báo cáo vẫn đang được tạo?
202 Accepted phản ánh chính xác trạng thái "đã tiếp nhận và vẫn đang xử lý". Báo cáo chưa sẵn sàng trong lúc đang tạo, nên endpoint trả về 202 và frontend tiếp tục polling; khi buổi phỏng vấn được đánh dấu hoàn tất, chính endpoint đó sẽ trả về báo cáo hoàn chỉnh.