AI Interview Module Database Guide
AI Interview Module Database Guide
AI Interview Module
Database Design & Implementation Guide
Portfolix LMS
Prepared for Parvathy
June 2026
Page 1
AI Interview Module — Database Guide
Table of Contents
TOC \h \o "1-3"
Page 2
AI Interview Module — Database Guide
1. Overview
This document lays out the database architecture for the Portfolix AI Interview Practice &
Evaluation System: how interview data is modeled, how recorded video and audio are stored
and eventually purged, and how a freemium / paid access layer can sit on top of it without
reworking the schema later. It is written for the current stack — [Link] / Express with
PostgreSQL, with a parallel Python (FastAPI + SQLAlchemy) implementation included for
any pieces built separately.
The guidance here is organized around three layers that work together but stay independent:
the core relational schema (templates, questions, attempts, answers, scores), the storage
and retention layer (where video/audio actually live and how a 30-day “recently deleted”
window is implemented), and the access-control layer (free vs. paid usage). Each is covered
in its own section below.
Instead, the actual files live in object storage — [Link] Stream is a natural fit given it is
already used elsewhere in the Portfolix stack, with S3 or Cloudflare R2 as alternatives. The
database only ever stores a reference: a URL or storage key pointing to where the file lives.
Every table below that mentions a video or audio field (video_url, audio_url) is storing a
string, not a file.
This separation is also what makes the 30-day retention system in Section 4 possible:
“deleting” a recording within the system is, for the first 30 days, purely a database operation
(flipping a flag), while the actual file removal from object storage is a separate, deliberate
step that only happens once after the window closes.
Page 3
AI Interview Module — Database Guide
model InterviewTemplate {
id String @id @default(uuid())
title String
description String?
category String // "Flutter Developer", "UI/UX Designer", etc.
difficulty Difficulty
interviewType InterviewType
totalQuestions Int
minPassingScore Int?
status TemplateStatus @default(DRAFT)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
questions InterviewQuestion[]
attempts InterviewAttempt[]
}
model InterviewQuestion {
id String @id @default(uuid())
templateId String
template InterviewTemplate @relation(fields: [templateId], references: [id])
questionText String
idealAnswer String? @[Link]
keywordsExpected String[] // native Postgres array, no join table needed
scoringRubric Json? // flexible per-question rubric overrides
preparationTime Int // seconds
answerTimeLimit Int // seconds
maxRetakes Int @default(1)
orderIndex Int
answers InterviewAnswer[]
}
model InterviewAttempt {
id String @id @default(uuid())
userId String
templateId String
template InterviewTemplate @relation(fields: [templateId], references: [id])
status AttemptStatus @default(IN_PROGRESS)
finalScore Float?
answerScore Float?
Page 4
AI Interview Module — Database Guide
speechScore Float?
bodyLanguageScore Float?
startedAt DateTime @default(now())
completedAt DateTime?
answers InterviewAnswer[]
adminReview AdminReview?
conversationTurns ConversationTurn[]
}
model InterviewAnswer {
id String @id @default(uuid())
attemptId String
attempt InterviewAttempt @relation(fields: [attemptId], references: [id])
questionId String
question InterviewQuestion @relation(fields: [questionId], references:
[id])
retakeCount Int @default(0)
videoUrl String
audioUrl String?
durationSec Int?
transcript String? @[Link]
transcriptEditedBy String? // admin user id, if manually edited
processingStatus ProcessingStatus @default(UPLOADED)
processingError String?
createdAt DateTime @default(now())
evaluation AnswerEvaluation?
speechReport SpeechReport?
postureReport PostureReport?
}
model AnswerEvaluation {
id String @id @default(uuid())
answerId String @unique
answer InterviewAnswer @relation(fields: [answerId], references:
[id])
overallScore Int
relevanceScore Int
clarityScore Int
knowledgeScore Int
exampleScore Int
communicationScore Int
businessUnderstandingScore Int
impactScore Int
Page 5
AI Interview Module — Database Guide
whatWentWell String[]
whatNeedsImprovement String[]
missingKeywords String[]
usedKeywords String[]
shortFeedback String @[Link]
detailedFeedback String @[Link]
refinedAnswer String @[Link]
hireReadiness HireReadiness
rawAiResponse Json? // raw model output, for debugging/audit
createdAt DateTime @default(now())
}
model SpeechReport {
id String @id @default(uuid())
answerId String @unique
answer InterviewAnswer @relation(fields: [answerId], references: [id])
wordsPerMinute Float
fillerWordCount Int
fillerWordBreakdown Json // { "um": 3, "like": 5, ... }
repeatedWords Json?
longPauseCount Int
pitchVariation Float?
speechRateScore Int // out of 5
fillerScore Int // out of 5
finalSpeechScore Int // out of 10
}
model PostureReport {
id String @id @default(uuid())
answerId String @unique
answer InterviewAnswer @relation(fields: [answerId], references:
[id])
faceCenteredScore Int // out of 5
eyeContactScore Int
shoulderAlignmentScore Int
headStraightScore Int
distanceScore Int
stabilityScore Int
finalPostureScore Int // out of 10
}
model AdminReview {
id String @id @default(uuid())
attemptId String @unique
attempt InterviewAttempt @relation(fields: [attemptId], references: [id])
reviewedBy String
manualScore Int?
Page 6
AI Interview Module — Database Guide
model ConversationTurn {
id String @id @default(uuid())
attemptId String
attempt InterviewAttempt @relation(fields: [attemptId], references:
[id])
turnIndex Int
aiQuestion String @[Link]
studentAnswerVideoUrl String?
transcript String? @[Link]
isFollowUp Boolean @default(false)
createdAt DateTime @default(now())
}
On the processing pipeline: an uploaded video lands in [Link] Stream, the upload
handler immediately writes an InterviewAnswer row with processingStatus UPLOADED, then
queues a job (BullMQ + Redis pairs naturally with the existing Node/Express stack). A
worker extracts and transcribes audio, updates the transcript and status to TRANSCRIBED,
then a second job runs the AI evaluation prompt against the transcript and writes the
AnswerEvaluation row. Speech and posture analysis can run as parallel jobs off the same
media rather than blocking the transcript-to-evaluation chain. Once every answer in an
attempt reaches EVALUATED, a final job aggregates the weighted final score (70% answer
evaluation, 15% speech, 15% body language per the project brief).
Page 7
AI Interview Module — Database Guide
model InterviewAnswer {
// ...existing fields...
isDeleted Boolean @default(false)
deletedAt DateTime?
purgeAt DateTime?
@@index([isDeleted, purgeAt])
}
The index matters: the daily cleanup job repeatedly queries WHERE isDeleted = true AND
purgeAt <= now(), and that needs to hit an index rather than scan the full table once there are
thousands of rows.
Page 8
AI Interview Module — Database Guide
where: { id },
data: {
isDeleted: true,
deletedAt,
purgeAt: new Date([Link]() + THIRTY_DAYS_MS),
},
});
[Link](updated);
});
[Link] deletion helper, used only by the purge job below — never on the delete/restore
routes themselves:
Page 9
AI Interview Module — Database Guide
The daily purge job — the only place a real file is ever deleted:
await [Link]({
where: { id: [Link] },
data: { videoUrl: null, audioUrl: null }, // row stays; scores/transcript
survive
});
} catch (err) {
[Link](`Failed to purge ${[Link]}:`, [Link]);
}
}
}
If BullMQ is already running for the AI pipeline, a repeatable BullMQ job is safer than node-cron
once more than one server instance is running, since cron schedules duplicate per-instance
while BullMQ repeatables do not.
class InterviewAnswer(Base):
__tablename__ = "interview_answers"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
attempt_id = Column(String, ForeignKey("interview_attempts.id"), nullable=False)
video_url = Column(String, nullable=True)
audio_url = Column(String, nullable=True)
transcript = Column(String, nullable=True)
# ...existing fields...
THIRTY_DAYS = timedelta(days=30)
Page 10
AI Interview Module — Database Guide
@[Link]("/answers/{answer_id}/delete", response_model=InterviewAnswerOut)
def soft_delete_answer(answer_id: str, db: Session = Depends(get_db),
user=Depends(get_current_user)):
answer = [Link](InterviewAnswer).join(InterviewAttempt).filter(
[Link] == answer_id
).first()
if not answer:
raise HTTPException(404, "Not found")
if [Link].user_id != [Link] and [Link] != "ADMIN":
raise HTTPException(403, "Not allowed")
now = [Link]()
answer.is_deleted = True
answer.deleted_at = now
answer.purge_at = now + THIRTY_DAYS
[Link]()
return answer
@[Link]("/answers/{answer_id}/restore", response_model=InterviewAnswerOut)
def restore_answer(answer_id: str, db: Session = Depends(get_db),
user=Depends(get_current_user)):
answer = [Link](InterviewAnswer).filter([Link] == answer_id).first()
if not answer or not answer.is_deleted:
raise HTTPException(400, "Nothing to restore")
answer.is_deleted = False
answer.deleted_at = None
answer.purge_at = None
[Link]()
return answer
Page 11
AI Interview Module — Database Guide
[Link]()
# [Link]
scheduler = AsyncIOScheduler()
@app.on_event("startup")
async def start_scheduler():
scheduler.add_job(purge_expired_answers, "cron", hour=3, minute=0)
[Link]()
Soft-delete on request:
UPDATE interview_answers
SET is_deleted = TRUE, deleted_at = NOW(), purge_at = NOW() + INTERVAL '30 days'
WHERE id = $1;
UPDATE interview_answers
SET is_deleted = FALSE, deleted_at = NULL, purge_at = NULL
WHERE id = $1;
Finding what is due for permanent removal — run by whichever script performs the actual
storage deletion:
UPDATE interview_answers
SET video_url = NULL, audio_url = NULL
WHERE id = $1;
Postgres can self-schedule the column-clearing half of this with the pg_cron extension, but it
cannot make the outbound HTTP call to [Link]'s delete API — Postgres does not talk to
external services on its own. So pg_cron alone leaves orphaned files sitting in storage; an
external script (Node or Python, as above) is what actually has to call Bunny's API and is the
only way to fully complete the purge.
Page 12
AI Interview Module — Database Guide
node-cron / Python Single server instance; simplest setup, runs inside the
APScheduler existing app process.
BullMQ repeatable job / Multiple server instances; avoids the job firing once per
Celery beat instance.
pg_cron Only suitable if file deletion from object storage is
handled by a separate process — pg_cron can clear
database columns but cannot call external APIs.
Page 13
AI Interview Module — Database Guide
model Payment {
id String @id @default(uuid())
userId String
provider String? // "razorpay" | "stripe" — fill in once decided
providerPaymentId String?
amount Int // in paise/cents
currency String @default("INR")
planType String // "single_attempt" | "credit_pack_5" |
"subscription_monthly"
creditsGranted Int?
status String @default("PENDING") // PENDING | SUCCESS | FAILED
createdAt DateTime @default(now())
}
Page 14
AI Interview Module — Database Guide
}
if ([Link] > 0) {
return { allowed: true, reason: 'paid_credit' };
}
return { allowed: false, reason: 'payment_required' };
}
Deduction happens only once an attempt is actually created — never on the paywall check
itself, so a student does not lose a credit just for loading the page:
SELECT
CASE
WHEN is_subscribed AND subscription_expires_at > NOW() THEN 'subscription'
WHEN free_attempts_used < free_attempts_limit THEN 'free_attempt'
WHEN paid_credits > 0 THEN 'paid_credit'
ELSE 'payment_required'
END AS access_reason
FROM user_entitlements
WHERE user_id = $1;
BEGIN;
UPDATE user_entitlements
SET paid_credits = paid_credits + 1, updated_at = NOW()
WHERE user_id = $1;
COMMIT;
Page 15
AI Interview Module — Database Guide
Page 16
AI Interview Module — Database Guide
• Payment gateway — Razorpay (typical default for INR pricing) vs. Stripe (adds
international card support).
• Pricing model — pay-per-attempt, a monthly/yearly subscription, or pre-purchased
credit packs.
• Scope of the free tier — one free attempt ever, or one free attempt per interview
category. This changes whether UserEntitlement is keyed by user alone or by (user,
category), and changes how the paywall message reads to the student.
• Whether discarded retakes should be logged for audit (a small RetakeLog table
keyed by answerId, videoUrl, and attempt number) or simply overwritten, since
retakeCount on InterviewAnswer already covers the basic case.
• Posture analysis approach — server-side frame extraction and pose estimation (e.g.
MediaPipe) versus a lighter in-browser pass that logs metrics as JSON during
recording. The latter is cheaper and avoids shipping every frame through the
pipeline, but only suits self-improvement feedback rather than scored evaluation.
Once the gateway and pricing model are settled, the only remaining build is the webhook
handler that turns a successful payment event into a Payment row and the corresponding
UserEntitlement update — the schema and access-check logic in Section 5 do not need to
change regardless of which option is chosen.
Page 17