AI Tools

Évaluer les réponses et générer un rapport d’entretien IA

Finalisez votre application d’entretien IA : évaluez chaque réponse orale, générez un rapport avec un agent et récupérez-le via un flux de sondage.

Photo de profil de Long Nguyen

Long Nguyen

Développeur fullstack · Ingénieur IA · Chercheur

• • 3 min de lecture •
Flux de rapport d’entretien IA : évaluer chaque réponse, générer un rapport final avec un agent, puis vérifier régulièrement jusqu’à sa disponibilité

Boucler la boucle

À l’étape 8, le candidat peut répondre à toutes les questions à l’oral. Mais un entretien sans verdict, ce n’est pas vraiment un entraînement : c’est simplement une conversation. Cette partie boucle le processus : nous évaluons chaque réponse et générons un rapport d’entretien avec l’IA, afin que le candidat sache comment il s’en est sorti et sur quels points progresser.

Le fonctionnement reprend celui du pipeline de génération des questions présenté précédemment : il s’agit d’un traitement lent — un appel à l’IA par réponse, puis un dernier appel de synthèse — qui s’exécute donc en tâche de fond, tandis que le frontend vérifie régulièrement si le rapport est prêt. Même modèle, nouvelle tâche.

Déclencher la génération du rapport

Le rapport ne peut être généré qu’une fois toutes les questions réellement répondues — évaluer un entretien incomplet serait trompeur. L’endpoint de déclenchement effectue donc ce contrôle avant toute autre opération :

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)

Deux contrôles fonctionnent ici de concert : l’entretien doit être dans l’état « started », et une requête confirme qu’il ne reste aucune question en attente (status=1). Ce n’est qu’alors que l’entretien passe à l’état « generating report » et que la tâche est confiée au même système de tâches en arrière-plan que celui de la partie 7. Réutiliser cette infrastructure est l’un des avantages de l’avoir conçue proprement dès le départ : une nouvelle tâche asynchrone ne nécessite que quelques lignes.

Évaluer, puis synthétiser

Dans la tâche en arrière-plan, le traitement se déroule en deux étapes, et leur ordre est important. Tout d’abord, chaque question ayant reçu une réponse est évaluée individuellement par scoring_question_agent : la réponse du candidat est fournie en entrée, puis un score et sa justification sont renvoyés sous forme de données structurées — selon l’approche présentée dans la partie 5. Ensuite, une fois toutes les réponses évaluées, interview_report_agent prend en compte l’ensemble des résultats et rédige le verdict final :

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

Cette séparation — « évaluer chaque élément, puis synthétiser l’ensemble » — est délibérée. Demander à un seul prompt d’évaluer toutes les réponses et de rédiger simultanément un verdict cohérent produit des résultats flous et irréguliers. Évaluer chaque réponse isolément permet de concentrer chaque jugement ; la synthèse peut ensuite s’appuyer sur des scores propres pour chaque question — le chiffre et sa justification — plutôt que sur les transcriptions brutes. C’est le même principe de composition par petites étapes que dans le pipeline de génération.

Un détail mérite que l’on s’y attarde : le schéma d’évaluation renvoie la justification avant le chiffre. Cet ordre est intentionnel : demander à l’agent d’expliquer d’abord son raisonnement, puis de choisir un score, produit une évaluation plus cohérente que de lui demander directement un simple chiffre. Le contenu précis des prompts — la manière d’évaluer équitablement, de rester cohérent face à des réponses très différentes et de résister à un candidat qui tenterait de parler pour obtenir la note maximale — constitue la partie la plus complexe et la plus déterminante pour le produit. Ces prompts optimisés sont inclus dans le starter kit, plutôt que proposés sous forme de simples éléments à copier-coller.

Vérifier, puis récupérer le rapport

Comme le rapport est généré en arrière-plan, le frontend ne peut pas l’obtenir en une seule requête. Il interroge un endpoint dédié, qui renvoie 202 Accepted tant que le traitement est en cours, puis le rapport final une fois celui-ci terminé :

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)

Le code 202 joue ici le même rôle de transparence que dans la partie 7 : « requête reçue, traitement toujours en cours, revenez vérifier ». Le frontend interroge l’endpoint toutes les quelques secondes ; dès que l’entretien passe à l’état terminé (status=7), ce même endpoint renvoie le contenu du rapport ainsi que le résultat de réussite ou d’échec. Un seul endpoint, deux significations, entièrement déterminées par le statut : simple à développer et simple à exploiter.

À propos de la confiance accordée aux données entrantes

Un principe discret a guidé tout ce pipeline : ne jamais prendre les données fournies par l’utilisateur pour argent comptant. Le texte du CV était considéré comme une donnée non fiable avant même d’être transmis à l’IA ; les réponses envoyées étaient validées avant leur enregistrement. Le même réflexe s’applique aux fichiers téléversés : un fichier qui prétend être un PDF ou un extrait audio doit être vérifié à partir de son contenu réel, et non de son nom. C’est un sujet à part qui mérite d’être compris correctement ; je l’ai donc traité dans Validation des fichiers téléversés en Python.

L’application est terminée

La boucle est complète. Un candidat téléverse son CV, l’IA construit un entretien personnalisé, il répond à voix haute, puis reçoit un rapport évalué qui lui indique comment il s’en est sorti. Tous les éléments du produit principal — parsing, pipeline d’agents, traitement en arrière-plan, voix, puis désormais évaluation et génération de rapports — sont en place et fonctionnent de bout en bout.

Il ne reste plus à ajouter de fonctionnalités, mais à déployer le tout sur Internet de manière fiable. Si vous préférez passer directement au code source complet, prêt pour la production, avec les prompts optimisés d’évaluation et de génération de rapports, vous le trouverez dans le starter kit. Sinon, la partie 10 est la prochaine — et dernière — étape : déployer l’application en production et la surveiller lorsque de vrais utilisateurs commenceront à l’utiliser.

FAQ

Questions fréquentes

Pourquoi évaluer chaque réponse séparément plutôt que toutes en même temps ?

Demander à un seul prompt d’évaluer toutes les réponses et de rédiger simultanément un verdict final produit des résultats flous et irréguliers. Évaluer chaque réponse isolément permet de concentrer chaque jugement ; la synthèse finale peut ensuite s’appuyer sur des scores propres pour chaque question plutôt que sur les transcriptions brutes.

Pourquoi le schéma d’évaluation renvoie-t-il la justification avant le score ?

Demander à l’agent d’expliquer d’abord son raisonnement, puis de choisir un chiffre, produit une évaluation plus cohérente que de lui demander directement un score. Il s’agit d’un petit choix dans l’ordre des données structurées, mais il améliore sensiblement la qualité.

Pourquoi la génération du rapport s’exécute-t-elle en tâche de fond ?

Le traitement est lent : un appel à l’IA par réponse, suivi d’un appel de synthèse, peut prendre plusieurs secondes. L’exécuter en dehors du cycle de la requête, avec le même système de tâches en arrière-plan que dans la partie 7, permet à l’application de rester réactive tandis que le frontend vérifie l’arrivée du résultat.

Pourquoi l’endpoint du rapport renvoie-t-il 202 pendant sa génération ?

Le statut 202 Accepted signifie clairement que la requête a été reçue et que le traitement est toujours en cours. Le rapport n’est pas encore disponible pendant la génération : l’endpoint renvoie donc 202 et le frontend continue ses vérifications. Une fois l’entretien marqué comme terminé, le même endpoint renvoie le rapport final.

Restez informé avec Netalith

Recevez des ressources de développement, des mises à jour produit et des offres spéciales directement dans votre boîte mail.