Thiết kế model database cho ứng dụng phỏng vấn AI bằng Django
Tìm hiểu cách thiết kế model Django gọn gàng cho ứng dụng phỏng vấn AI, với quan hệ Interview, Question và Answer có thể mở rộng theo quy mô.
Long Nguyen
Lập trình viên Fullstack · Kỹ sư AI · Nhà nghiên cứu
Một buổi phỏng vấn thực sự tạo ra những gì?
Trước khi viết dù chỉ một field, bạn nên thiết kế các model Django cho ứng dụng phỏng vấn AI dựa trên bản chất của một buổi phỏng vấn, thay vì chọn cách nhanh nhất để bắt đầu. Ở phần 1, chúng ta đã chọn stack và lên kế hoạch cho các tính năng. Bây giờ là lúc biến kế hoạch đó thành một cấu trúc dữ liệu thực tế.
Hãy hình dung một phiên phỏng vấn tạo ra những dữ liệu sau:
- một buổi phỏng vấn gắn với vị trí tuyển dụng và CV, trải qua vòng đời từ đang chờ xử lý đến hoàn tất;
- một tập hợp các câu hỏi, mỗi câu có chủ đề, độ khó và vị trí riêng trong buổi phỏng vấn;
- một câu trả lời của người dùng cùng điểm số gắn với từng câu hỏi;
- một báo cáo cuối cùng tóm tắt toàn bộ phiên phỏng vấn.
Hãy mô hình hóa domain trước, rồi các field gần như sẽ tự trở nên rõ ràng. Nếu bắt tay vào khai báo field ngay, sau này bạn có thể phải định hình lại schema — đây là kiểu thay đổi tốn kém nhất khi dữ liệu thực tế đã tích lũy trong hệ thống.
Các model cốt lõi
Luồng phỏng vấn dựa trên ba model: Interview, InterviewQuestion và InterviewReport. Interview là phiên chính; các câu hỏi thuộc về phiên đó; còn report tóm tắt toàn bộ phiên. Một model hỗ trợ nhỏ là DeviceToken, giúp ứng dụng nhận diện thiết bị quay lại mà không yêu cầu đăng nhập.
import uuid
from django.db import models
class DeviceToken(TimeInfo):
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
class Meta:
db_table = "device_tokens"
class Interview(TimeInfo):
INTERVIEW_STATUS = [
(1, "Pending"),
(2, "Processing Resume"),
(3, "Preparing Interview"),
(4, "In Progress"),
(5, "Started"),
(6, "Generating Report"),
(7, "Completed"),
(8, "Failed"),
]
job_title = models.CharField(max_length=255, null=True, blank=True)
interview_uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
cv_file = models.FileField(upload_to="storage/files/", null=True, blank=True)
cv_text = models.TextField(null=True, blank=True)
status = models.SmallIntegerField(default=1, choices=INTERVIEW_STATUS)
candidate_name = models.CharField(max_length=100, null=True, blank=True)
candidate_email = models.EmailField(null=True, blank=True)
device = models.ForeignKey(
DeviceToken, on_delete=models.SET_NULL, null=True, blank=True
)
client_ip = models.GenericIPAddressField(
null=True, blank=True, db_index=True
) # for per-IP daily rate limit
class Meta:
db_table = "interviews"
def __str__(self):
return str(self.interview_uuid)
class InterviewQuestion(TimeInfo):
QUESTION_LEVEL_CHOICES = (
("easy", "Easy"),
("medium", "Medium"),
("hard", "Hard"),
)
interview = models.ForeignKey(Interview, on_delete=models.CASCADE)
topic = models.CharField(max_length=255, null=True, blank=True)
position = models.IntegerField(default=1)
question = models.TextField(null=True, blank=True)
sample_answer = models.TextField(null=True, blank=True) # suggested model answer
user_answer = models.TextField(null=True, blank=True)
user_score = models.SmallIntegerField(null=True, blank=True)
reason_score = models.TextField(null=True, blank=True)
level = models.CharField(
max_length=10, null=True, blank=True, choices=QUESTION_LEVEL_CHOICES
)
record = models.FileField(
upload_to="storage/audio/", null=True, blank=True
) # voice answer recording
class Meta:
db_table = "interview_questions"
def __str__(self):
return f"{self.question}"
class InterviewReport(TimeInfo):
interview = models.OneToOneField(Interview, on_delete=models.CASCADE)
content = models.TextField(null=True, blank=True)
is_passed = models.BooleanField(default=False)
class Meta:
db_table = "interview_reports"
Có một số lựa chọn đáng chú ý. Field status sử dụng vòng đời bằng integer choices để giai đoạn chính xác của buổi phỏng vấn — từ xử lý CV đến tạo report — luôn được xác định rõ ràng, không phải phỏng đoán. Mỗi câu hỏi có topic, level và position riêng, giúp khôi phục đúng thứ tự với độ khó tăng dần, đồng thời lưu cả sample_answer (câu trả lời mẫu được đề xuất) và user_answer của ứng viên bên cạnh nhau. Field record lưu bản ghi âm câu trả lời, tạo nền tảng cho việc luyện nói bằng giọng nói sau này. Còn interview_uuid cung cấp cho mỗi phiên một mã định danh công khai không tuần tự, nhờ đó URL không tiết lộ có bao nhiêu buổi phỏng vấn và người khác cũng không thể đoán được phiên tiếp theo.
Bạn cũng có thể thấy device và client_ip nằm trên interview. Ứng dụng được thiết kế không yêu cầu đăng nhập, vì vậy sử dụng device token đã băm cùng địa chỉ IP của client để áp dụng giới hạn hợp lý theo từng thiết bị và từng IP mỗi ngày — người dùng vẫn có thể luyện tập nghiêm túc, trong khi việc lạm dụng các AI endpoint bị hạn chế.
Quan hệ giữa các model và lựa chọn on_delete
Các quan hệ này mang ý nghĩa thực tế, vì vậy cần được quyết định có chủ đích thay vì sao chép một giá trị mặc định:
| Quan hệ | Loại field | Ý nghĩa | on_delete |
|---|---|---|---|
| Interview → InterviewQuestion | ForeignKey | Một interview có nhiều câu hỏi | CASCADE |
| Interview → InterviewReport | OneToOneField | Mỗi interview có đúng một report | CASCADE |
| Interview → DeviceToken | ForeignKey | Một interview được liên kết với thiết bị đã khởi tạo nó | SET_NULL |
on_delete=CASCADE là lựa chọn phù hợp cho câu hỏi và report: đây là dữ liệu thuộc phiên, nên khi xóa interview thì các dữ liệu này cũng nên được xóa theo. Để lại các dòng dữ liệu mồ côi sẽ là lỗi, không phải tính năng. Sử dụng OneToOneField cho report cũng buộc quy tắc này ở cấp database — một interview không thể vô tình có hai report. Ngược lại, liên kết đến thiết bị dùng SET_NULL, vì vòng đời của thiết bị và interview độc lập: xóa device token không nên xóa các buổi phỏng vấn mà người dùng đã thực hiện.
Chạy migration đầu tiên
Model chỉ mới là Python code cho đến khi bạn chuyển chúng thành các table trong database. Django thực hiện việc này qua hai bước — một bước tạo file migration và một bước áp dụng migration đó:
python manage.py makemigrations
python manage.py migrate
makemigrations đọc các model rồi ghi một migration mô tả thay đổi; migrate chạy migration đó trên database. Bạn nên mở file migration được tạo ít nhất một lần để xem SQL mà Django sắp thực thi — hiểu được những gì diễn ra bên dưới sẽ giúp migration bớt giống “phép thuật” khi có lỗi xảy ra.
Một lưu ý thực tế: SQLite rất tiện cho môi trường phát triển cục bộ, nhưng không thực thi mọi constraint giống PostgreSQL — giới hạn độ dài field và một số cơ chế validation có thể hoạt động khác nhau. Nếu dự định triển khai trên PostgreSQL, hãy phát triển với PostgreSQL từ sớm để không phát hiện khác biệt khi hệ thống đã chạy production.
Đây là phần cốt lõi của data model cho ứng dụng phỏng vấn. Ứng dụng hoàn chỉnh sẽ có thêm nhiều lớp — hệ thống background task để xử lý ngoài request cycle, cơ chế xóa mềm các interview cũ, cùng những thành phần giúp câu trả lời bằng giọng nói và cơ chế chống lạm dụng đủ an toàn cho production. Các phần đó và toàn bộ phần còn lại của quá trình xây dựng đã được đóng gói trong starter kit. Nếu không, hãy đọc tiếp phần 3, nơi chúng ta xử lý việc tải CV lên và trích xuất văn bản sạch từ CV PDF bằng pdfplumber.
CÂU HỎI THƯỜNG GẶP
Câu hỏi thường gặp
Vì sao nên tách Question và Answer thành hai model riêng?
Việc tách riêng giúp mỗi câu hỏi có metadata riêng, còn mỗi câu trả lời có điểm số và phản hồi riêng. Nếu gộp chúng lại, bạn sẽ phải dùng nhiều field có thể null và việc truy vấn theo từng câu hỏi sẽ trở nên khó khăn khi ứng dụng mở rộng.
Nên dùng SQLite hay PostgreSQL cho dự án này?
SQLite phù hợp cho môi trường local, nhưng hành vi của nó khác PostgreSQL về validation và constraint. Nếu dự định triển khai, phát triển với PostgreSQL từ sớm sẽ giúp tránh những vấn đề bất ngờ về sau.
on_delete=CASCADE thực hiện điều gì trong trường hợp này?
Khi xóa một interview, hệ thống sẽ tự động xóa các câu hỏi và câu trả lời của interview đó, nhờ vậy không để lại các dòng dữ liệu mồ côi. Với một ứng dụng dựa trên từng phiên, đây thường là hành vi bạn mong muốn.