AI Tools

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ô.

Ảnh đại diện Long Nguyen

Long Nguyen

Lập trình viên Fullstack · Kỹ sư AI · Nhà nghiên cứu

3 phút đọc
Sơ đồ database cho ứng dụng phỏng vấn AI: các model Interview, InterviewQuestion và InterviewReport cùng mối quan hệ giữa chúng

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.

Base model dùng chung cho timestamp

Gần như model nào cũng cần các field quản trị giống nhau: thời điểm tạo và thời điểm cập nhật gần nhất. Thay vì lặp lại chúng trong từng model, hãy định nghĩa một lần dưới dạng abstract base rồi cho các model khác kế thừa. Đây là một nguyên tắc nhỏ giúp toàn bộ schema nhất quán hơn.

from django.db import models
from django.utils.timezone import localtime


class TimeInfo(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    metadata = models.JSONField(default=dict, null=True, blank=True)

    class Meta:
        ordering = ("-created_at",)
        abstract = True

    @property
    def created_at_display(self):
        return localtime(self.created_at).strftime("%d-%m-%Y %H:%M:%S")

abstract = True, Django sẽ không tạo table cho riêng TimeInfo — các field của nó chỉ được thêm vào những model kế thừa class này. Field JSON metadata là một “lối thoát” có chủ đích: nơi lưu trữ dữ liệu bổ sung có thể thay đổi theo thời gian mà không cần tạo migration mỗi khi phát sinh nhu cầu mới.

Các model cốt lõi

Luồng phỏng vấn dựa trên ba model: Interview, InterviewQuestionInterviewReport. 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, levelposition 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 deviceclient_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.

Cập nhật cùng Netalith

Nhận tài nguyên lập trình, cập nhật sản phẩm và ưu đãi đặc biệt ngay trong hộp thư của bạn.