/** * Contrôleur génération de présentation T1 — Sprint 4a. * * Génère un texte de présentation personnelle (Tâche 1 EO) à partir des * 5 réponses fournies par le candidat. Pas de stockage en base (le frontend * gère la persistance locale pour le MVP). * * Paramètres DeepSeek : temperature 0.7, max_tokens 600, timeout 20s. * Pas de response_format json — on récupère du texte brut. */ const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? ""; const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; export interface PresentationReponses { prenom_age_ville: string; formation_metier: string; situation_familiale: string; loisirs: string; motivation_canada: string; } export type PresentationError = { error: true; code: string; message: string; status: number; }; const REQUIRED_FIELDS: (keyof PresentationReponses)[] = [ "prenom_age_ville", "formation_metier", "situation_familiale", "loisirs", "motivation_canada", ]; export function validateReponses( raw: unknown, ): { ok: true; reponses: PresentationReponses } | PresentationError { if (typeof raw !== "object" || raw === null) { return { error: true, code: "VALIDATION_ERROR", message: "`reponses` est requis et doit être un objet.", status: 400, }; } const r = raw as Record; for (const field of REQUIRED_FIELDS) { const v = r[field]; if (typeof v !== "string" || v.trim().length === 0) { return { error: true, code: "VALIDATION_ERROR", message: `Le champ \`reponses.${field}\` est requis et ne doit pas être vide.`, status: 400, }; } } return { ok: true, reponses: { prenom_age_ville: (r.prenom_age_ville as string).trim(), formation_metier: (r.formation_metier as string).trim(), situation_familiale: (r.situation_familiale as string).trim(), loisirs: (r.loisirs as string).trim(), motivation_canada: (r.motivation_canada as string).trim(), }, }; } export function buildPresentationPrompt( reponses: PresentationReponses, ): string { return `Tu es un coach TCF Canada spécialisé en Expression Orale. Tu rédiges des textes que le candidat va LIRE À VOIX HAUTE devant un examinateur (entretien dirigé, ~2 minutes). Informations à intégrer fidèlement (ne rien inventer) : - Identité : ${reponses.prenom_age_ville} - Formation / métier : ${reponses.formation_metier} - Famille : ${reponses.situation_familiale} - Loisirs : ${reponses.loisirs} - Projet Canada : ${reponses.motivation_canada} OBJECTIF : produire une présentation personnelle pour la Tâche 1 TCF Canada, longueur cible **220 à 260 mots** (durée réaliste à l'oral, ni trop courte ni trop longue). STRUCTURE À RESPECTER (dans cet ordre) : 1) Identité et cadre (qui vous êtes, où vous vivez si pertinent) 2) Formation / parcours professionnel 3) Situation familiale 4) Loisirs ou passions 5) Projet d'immigration au Canada 6) Une **courte** phrase de transition finale vers l'examinateur (ex. proposer de développer un point), **sans** être familière ni utiliser « tu » STYLE ORAL (prioritaire) : - Phrases **courtes à moyennes**, faciles à dire d'un seul souffle ; éviter les phrases alambiquées ou les subordonnées empilées. - **Enchaînements parlés** : alterner des liens simples (« Ensuite », « Côté famille », « Pour les loisirs », « Concernant mon projet… », « Voilà, en résumé… ») plutôt qu'un style dissertation. - Vocabulaire **correct mais accessible** ; privilégier les mots usuels. Pas de jargon inutile ni de tournures trop littéraires (« Il convient de », « En outre », « Néanmoins », « Ainsi donc »). - **Éviter le style écrit** : pas de listes à puces, pas de titres, pas d'introduction type « Je vais vous parler de… en trois parties ». - **Fluidité à prononcer** : éviter les enchaînements de voyelles ou de consonnes lourdes quand c'est simple à reformuler ; favoriser la respiration naturelle (points, virgules logiques à l'oral). - Registre **semi-formel** : poli, respectueux, comme face à un examinateur ; pas de slang, pas de tutoiement de l'examinateur, pas d'excès de familiarité. Ce qu'il faut éviter : - Ton académique, catalogué ou « corrigé de dissertation » - Répétitions mécaniques du même connecteur (ex. « En ce qui concerne » à chaque paragraphe) - Phrases trop longues ou trop complexes à mémoriser Réponds **UNIQUEMENT** avec le texte continu de la présentation (première personne), sans titre, sans guillemets, sans commentaire ni note.`; } export async function generate( rawReponses: unknown, ): Promise<{ data: { presentation: string } } | PresentationError> { const validation = validateReponses(rawReponses); if ("error" in validation) return validation; const systemPrompt = buildPresentationPrompt(validation.reponses); let response: Response; try { response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ model: "deepseek-chat", messages: [{ role: "system", content: systemPrompt }], temperature: 0.7, max_tokens: 600, }), signal: AbortSignal.timeout(20_000), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error("[presentationController.generate] fetch failed", { message, }); return { error: true, code: "INTERNAL_ERROR", message: "Impossible de générer la présentation. Veuillez réessayer dans quelques instants.", status: 500, }; } if (!response.ok) { console.error("[presentationController.generate] DeepSeek non-OK", { status: response.status, statusText: response.statusText, }); return { error: true, code: "INTERNAL_ERROR", message: "Impossible de générer la présentation. Veuillez réessayer dans quelques instants.", status: 500, }; } const data = (await response.json()) as { choices?: { message?: { content?: string } }[]; }; const presentation = data.choices?.[0]?.message?.content?.trim(); if (!presentation || presentation.length === 0) { return { error: true, code: "INTERNAL_ERROR", message: "Réponse de génération vide. Veuillez réessayer.", status: 500, }; } return { data: { presentation } }; }