AI Tools

Scoring Answers and Generating an AI Interview Report

Close the loop on an AI interview app: score each spoken answer, generate a final report with an AI agent, and expose it through a clean poll-and-fetch flow.

Long Nguyen Avatar

Long Nguyen

Fullstack Developer · AI Engineer · Researcher

3 min read
AI interview report flow: score each answer, generate a final report with an agent, then poll until it is ready

Closing the Loop

By part 8 the candidate can answer every question by voice. But an interview with no verdict isn't practice — it's just talking. This part closes the loop: we score each answer and generate an AI interview report, so the candidate walks away knowing how they did and where to improve.

The shape mirrors the question-generation pipeline from earlier: it's slow work — one AI call per answer, plus a final summarizing call — so it runs as a background task, and the frontend polls until the report is ready. Same pattern, new job.

Triggering the Report

The report can only run once every question is actually answered — scoring a half-finished interview would be misleading. So the trigger endpoint guards on that before it does anything:

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)

Two guards work together here: the interview must be in the "started" state, and a query confirms zero questions are still pending (status=1). Only then does it flip the interview to "generating report" and hand the job off to the same background task system from part 7. Reusing that machinery is the payoff of having built it cleanly once — a whole new async job costs just a few lines.

Scoring, Then Summarizing

Inside the background task, the work happens in two stages, and the order matters. First, each answered question is scored on its own by the scoring_question_agent — the candidate's answer goes in, a score and a justification come back as structured output (the approach from part 5). Then, once every answer has a score, the interview_report_agent reads the whole picture and writes the final verdict:

@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"])

Splitting it into "score each part, then summarize the whole" is deliberate. Asking one prompt to both grade every answer and write a coherent verdict at once produces mushy, inconsistent results. Scoring each answer in isolation keeps each judgment focused; the summarizing pass then reasons over clean per-question scores — the number and the justification — instead of raw transcripts. It's the same compose-small-steps principle as the generation pipeline.

One detail is worth pausing on: the scoring schema returns the justification before the number. That ordering is intentional — making the agent explain its reasoning first, then commit to a score, produces more consistent grading than asking for a bare number up front. What the prompts actually say — how they grade fairly, stay consistent across wildly different answers, and resist a candidate trying to talk their way to a perfect score — is the hard, product-defining part, and those tuned prompts are in the starter kit rather than something to copy-paste.

Poll, Then Fetch the Report

Because the report runs in the background, the frontend can't get it in one request. It polls a report endpoint, which returns 202 Accepted while the work is still running and the finished report once it's done:

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)

The 202 is doing the same honest job it did in part 7: "accepted, still working, check back." The frontend polls every couple of seconds, and the moment the interview flips to completed (status=7), the same endpoint hands back the report content and the pass/fail result. One endpoint, two meanings, driven entirely by status — simple to build and simple to consume.

A Note on Trusting Input

One theme has run quietly under this whole pipeline: never trust what comes from the user at face value. The resume text was treated as untrusted data before the AI ever read it; the answer submissions were validated before being stored. That same instinct applies to the uploaded files themselves — a file claiming to be a PDF or an audio clip has to be verified by its real content, not its name. That's its own topic worth understanding properly, and I wrote it up separately in File Upload Validation in Python.

The App Is Complete

That's the full loop. A candidate uploads a CV, an AI builds a tailored interview, they answer out loud, and now they get a scored report telling them how they did. Every piece of the core product — parsing, the agent pipeline, background processing, voice, and now scoring and reporting — is in place and working end to end.

What's left isn't features; it's getting this onto the internet reliably. If you'd rather skip ahead to the complete, production-ready source — the tuned scoring and report prompts included — it's all in the starter kit. Otherwise, part 10 is next and last: deploying the app to production and keeping an eye on it once real users arrive.

FAQ

Frequently asked questions

Why score each answer separately instead of all at once?

Asking one prompt to grade every answer and write a final verdict at the same time produces inconsistent, mushy results. Scoring each answer in isolation keeps each judgment focused, and the final summarizing pass then reasons over clean per-question scores rather than raw transcripts.

Why does the scoring schema return the reason before the score?

Making the agent explain its reasoning first, then commit to a number, produces more consistent grading than asking for a bare score up front. It's a small ordering choice in the structured output that meaningfully improves quality.

Why does report generation run as a background task?

It's slow — one AI call per answer plus a final summarizing call can take many seconds. Running it off the request cycle, using the same background task system from part 7, keeps the app responsive while the frontend polls for the result.

Why does the report endpoint return 202 while it's still generating?

202 Accepted honestly means 'received and still processing.' The report isn't ready during generation, so the endpoint returns 202 and the frontend keeps polling; once the interview is marked completed, the same endpoint returns the finished report.

Stay updated with Netalith

Get coding resources, product updates, and special offers directly in your inbox.