diff --git a/apps/api/app/controllers/ai_controller.ts b/apps/api/app/controllers/ai_controller.ts index 4096164..1c8b1d5 100644 --- a/apps/api/app/controllers/ai_controller.ts +++ b/apps/api/app/controllers/ai_controller.ts @@ -11,7 +11,9 @@ const generateRelanceValidator = vine.create({ // Brief libre. On accepte vide : Mistral génère alors un message standard // pour la tonalité + timing donnés. prompt: vine.string().maxLength(1000).optional(), - planContext: vine.string().maxLength(500).optional(), + // Contexte du plan parent — nom + description, pour cohérence inter-étapes. + planName: vine.string().maxLength(80).optional(), + planDescription: vine.string().maxLength(500).optional(), }) /** @@ -35,7 +37,8 @@ export default class AiController { tone: payload.tone, offsetDays: payload.offsetDays, prompt: payload.prompt ?? '', - planContext: payload.planContext, + planName: payload.planName, + planDescription: payload.planDescription, }) return response.json({ data: result }) } catch (err) { diff --git a/apps/api/app/services/ai_relance_generator.ts b/apps/api/app/services/ai_relance_generator.ts index fba2fea..3fa384c 100644 --- a/apps/api/app/services/ai_relance_generator.ts +++ b/apps/api/app/services/ai_relance_generator.ts @@ -15,8 +15,10 @@ export type GenerateRelanceInput = { offsetDays: number /** Brief libre rédigé par l'utilisateur (ex. "rappelle qu'on accepte les virements"). */ prompt: string - /** Contexte du plan, pour cohérence (ex. nom du plan, étapes voisines). */ - planContext?: string + /** Nom du plan parent — donne du contexte au modèle (ex. "Clients fidèles"). */ + planName?: string + /** Description du plan parent — quand utiliser ce plan, ICP visé. */ + planDescription?: string } export type GenerateRelanceOutput = { @@ -35,30 +37,36 @@ const TONE_GUIDANCE: Record = { "Ton formel et juridique. Mentionne explicitement 'mise en demeure', un délai de paiement (8 jours), et les conséquences légales (pénalités de retard, voie judiciaire). Reste factuel, pas émotionnel.", } -const SYSTEM_PROMPT = `Tu rédiges des emails de relance de factures impayées en français pour une TPE-PME. +const SYSTEM_PROMPT = `Tu rédiges des emails de relance de factures impayées en français pour une TPE-PME française. -Règles strictes : +# Règles de rédaction - Toujours en français. -- Toujours tutoyer le **destinataire** ? NON, vouvoie systématiquement (B2B France). -- Reste concis : 4 à 8 phrases maximum pour le corps. -- Ne saute jamais de salutation ni de signature. -- Insère naturellement les variables fournies (sans les commenter). -- Si le prénom du contact n'est pas dispo, retombe sur une formule générale ("Bonjour,"). +- Vouvoie systématiquement le destinataire (B2B France). +- Concis : 4 à 8 phrases maximum pour le corps. +- Une salutation au début, et termine TOUJOURS le corps par {{signature}} sur sa propre ligne. **Ne jamais réécrire le nom de l'expéditeur ni l'entreprise à la main après {{signature}}** : la variable contient déjà tout (nom + entreprise + formule de politesse choisie par l'utilisateur). -Variables disponibles à insérer (utilise la syntaxe Mustache exacte) : -- {{client.name}} : raison sociale du client -- {{client.contactFirstName}} : prénom du contact (peut être vide → fallback "Bonjour,") +# Syntaxe des variables — IMPORTANT +- Utilise UNIQUEMENT la substitution simple \`{{nom.de.variable}}\`. +- N'utilise JAMAIS la syntaxe de sections \`{{#var}}...{{/var}}\`, \`{{^var}}...{{/var}}\`, ni aucune syntaxe conditionnelle. Notre interpréteur ne fait que de la substitution simple — toute syntaxe avancée s'affichera telle quelle dans l'email final. +- Tu n'es **PAS obligé** d'utiliser toutes les variables. Choisis celles qui rendent le message naturel et utile. Mieux vaut un message simple et clair qu'un message bourré de variables. + +# Variables disponibles +- {{client.name}} : raison sociale du client (toujours rempli) +- {{client.contactFirstName}} : prénom du contact (peut être vide à l'envoi — dans ce cas la variable s'efface silencieusement, donc préfère une formule qui marche dans les deux cas, ex. "Bonjour {{client.contactFirstName}}," où l'absence du prénom donne juste "Bonjour ,") - {{client.contactLastName}} : nom du contact (peut être vide) - {{numero}} : numéro de la facture -- {{amount}} : montant TTC formaté ("1 240,00 €") -- {{dueDate}} : date d'échéance ("15/04/2026") +- {{amount}} : montant TTC formaté (ex. "1 240,00 €") +- {{dueDate}} : date d'échéance (ex. "15/04/2026") - {{issueDate}} : date d'émission -- {{daysLate}} : nombre de jours de retard (entier) -- {{user.fullName}} : nom de l'expéditeur (la TPE) +- {{daysLate}} : jours de retard (entier — peut être négatif si la relance est avant échéance) +- {{user.fullName}} : nom de l'expéditeur (rarement utile dans le corps si on a déjà {{signature}}) - {{user.companyName}} : nom de l'entreprise expéditrice -- {{signature}} : bloc signature de l'expéditeur +- {{signature}} : bloc signature complet — termine TOUJOURS le corps par cette variable -Tu retournes un JSON strict avec deux clés : "subject" (max 100 caractères) et "body" (max 2000 caractères).` +# Format de retour +JSON strict avec deux clés : +- "subject" : sujet (max 100 caractères, naturel, peut contenir {{numero}}) +- "body" : corps de l'email` /** * Génère un email de relance via Mistral. Retourne `{ subject, body }` @@ -72,22 +80,28 @@ export async function generateRelance(input: GenerateRelanceInput): Promise= 0 ? '+' : ''}${input.offsetDays} (${ - input.offsetDays < 0 - ? "rappel avant l'échéance" - : input.offsetDays === 0 - ? 'jour de l\'échéance' - : `${input.offsetDays} jours après l'échéance` - }).`, - input.planContext ? `Contexte du plan : ${input.planContext}` : null, + '# Plan parent', + input.planName ? `Nom : ${input.planName}` : 'Nom : (non précisé)', + input.planDescription + ? `Description : ${input.planDescription}` + : 'Description : (aucune)', '', - 'Brief de l\'utilisateur :', - input.prompt.trim() || '(aucun brief — génère un message standard pour cette tonalité et ce timing)', - ] - .filter(Boolean) - .join('\n') + '# Cette relance', + `Tonalité : ${input.tone} → ${TONE_GUIDANCE[input.tone]}`, + `Timing : J${input.offsetDays >= 0 ? '+' : ''}${input.offsetDays} → ${offsetExplanation}.`, + '', + "# Brief de l'utilisateur", + input.prompt.trim() || + '(aucun brief — rédige un message standard pour cette tonalité et ce timing, en restant naturel)', + ].join('\n') const res = await fetch(`${MISTRAL_API}/chat/completions`, { method: 'POST', diff --git a/apps/web/src/components/plans/wizard/AiGenerateModal.tsx b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx index 9696a14..ebc6866 100644 --- a/apps/web/src/components/plans/wizard/AiGenerateModal.tsx +++ b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from "react"; import { useMutation } from "@tanstack/react-query"; -import { Sparkles, RefreshCw } from "lucide-react"; +import { Sparkles, RefreshCw, Info } from "lucide-react"; import { toast } from "sonner"; import type { RelanceTone } from "@rubis/shared"; import { api } from "@/lib/api"; -import { TONE_LABELS } from "@/lib/plans"; +import { TONE_LABELS, TEMPLATE_VARIABLES } from "@/lib/plans"; import { Dialog, DialogContent, @@ -35,27 +35,30 @@ const DEFAULT_PROMPTS: Record = { /** * Modale qui appelle l'IA pour générer le subject + body d'une étape. - * L'utilisateur peut régénérer pour avoir une variante avant d'accepter. + * Le contexte envoyé à Mistral inclut : nom + description du plan parent, + * tonalité + timing de la relance, brief de l'utilisateur, et la liste + * des variables disponibles (pas obligées d'être utilisées toutes). */ export function AiGenerateModal({ open, onOpenChange, tone, offsetDays, - planContext, + planName, + planDescription, onAccept, }: { open: boolean; onOpenChange: (open: boolean) => void; tone: RelanceTone; offsetDays: number; - planContext?: string; + planName?: string; + planDescription?: string; onAccept: (result: GenerateResult) => void; }) { const [prompt, setPrompt] = useState(DEFAULT_PROMPTS[tone]); const [result, setResult] = useState(null); - // Reset à chaque ouverture pour repartir d'un état propre. useEffect(() => { if (open) { setPrompt(DEFAULT_PROMPTS[tone]); @@ -69,7 +72,8 @@ export function AiGenerateModal({ tone, offsetDays, prompt, - planContext, + planName, + planDescription, }), onSuccess: (data) => setResult(data), onError: () => toast.error("Génération impossible. Réessayez dans un instant."), @@ -85,8 +89,10 @@ export function AiGenerateModal({ - L'IA va rédiger un email de relance avec une tonalité{" "} - {TONE_LABELS[tone].toLowerCase()}, programmé J + Pour le plan{" "} + {planName || "(sans nom)"}, + l'IA va rédiger une relance de tonalité{" "} + {TONE_LABELS[tone].toLowerCase()} programmée J {offsetDays >= 0 ? "+" : ""} {offsetDays}. Affinez le brief si besoin. @@ -96,7 +102,7 @@ export function AiGenerateModal({