fix(plans/ai): contexte plan + interdiction Mustache sections

Bugs remontés sur les générations IA :
- Le modèle utilisait `{{#var}}...{{/var}}` (sections Mustache) pour
  gérer les fallbacks de prénom — notre interpréteur ne fait que de
  la substitution simple, donc le charabia s'affichait dans l'email.
- La signature était dupliquée : l'IA écrivait le nom à la main puis
  ajoutait `{{signature}}`.
- Le contexte du plan (nom + description) n'était pas transmis, donc
  les générations étaient déconnectées du sens du plan parent.

Corrections du SYSTEM_PROMPT :
- Section "Syntaxe des variables" explicite : substitution simple
  uniquement, INTERDICTION des `{{#...}}` / `{{^...}}` / conditionnels
- Section "Tu n'es PAS obligé d'utiliser toutes les variables"
  → l'IA pioche celles qui rendent le message naturel
- Règle : terminer toujours par {{signature}} sur sa propre ligne,
  ne JAMAIS réécrire le nom de l'expéditeur après (la variable
  contient déjà nom + entreprise + formule de politesse)

Backend
- ai_relance_generator : type GenerateRelanceInput accepte planName
  + planDescription (à la place de l'ancien planContext fourre-tout)
- user message structuré en sections # Plan parent / # Cette relance
  / # Brief de l'utilisateur, plus lisible pour le modèle
- ai_controller validator : accepte planName + planDescription

Frontend
- AiGenerateModal accepte planName + planDescription en props et
  les passe à l'API
- Affiche le nom du plan dans la description de la modale
- Bloc dépliable "Variables que l'IA peut insérer (sans obligation)"
  pour montrer à l'utilisateur ce qui est dispo
- StepMessages passe draft.name + draft.description au modal
- MSW handler aligné sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 23:48:57 +02:00
parent 0a3b8523ef
commit 5c7dbc2eba
5 changed files with 94 additions and 46 deletions

View File

@ -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) {

View File

@ -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<RelanceTone, string> = {
"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}}," 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<Gene
throw new Error('MISTRAL_API_KEY manquante : génération IA indisponible.')
}
const offsetExplanation =
input.offsetDays < 0
? `${Math.abs(input.offsetDays)} jours **avant** l'échéance (rappel anticipé)`
: input.offsetDays === 0
? "le **jour J** de l'échéance"
: `${input.offsetDays} jours **après** l'échéance (la facture est en retard)`
const userMessage = [
`Tonalité ciblée : ${input.tone}${TONE_GUIDANCE[input.tone]}`,
`Position dans le plan : J${input.offsetDays >= 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',

View File

@ -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<RelanceTone, string> = {
/**
* 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<GenerateResult | null>(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({
</span>
</DialogTitle>
<DialogDescription>
L'IA va rédiger un email de relance avec une tonalité{" "}
<strong>{TONE_LABELS[tone].toLowerCase()}</strong>, programmé J
Pour le plan{" "}
<strong className="text-ink-2">{planName || "(sans nom)"}</strong>,
l'IA va rédiger une relance de tonalité{" "}
<strong>{TONE_LABELS[tone].toLowerCase()}</strong> programmée J
{offsetDays >= 0 ? "+" : ""}
{offsetDays}. Affinez le brief si besoin.
</DialogDescription>
@ -96,7 +102,7 @@ export function AiGenerateModal({
<Field
label="Brief pour l'IA"
htmlFor="ai-prompt"
hint="Décrivez ce que vous voulez transmettre. Plus vous êtes précis, mieux c'est."
hint="Ce que vous voulez transmettre. Plus c'est précis, mieux c'est."
>
<Textarea
id="ai-prompt"
@ -107,6 +113,29 @@ export function AiGenerateModal({
/>
</Field>
<details className="rounded-default border border-line bg-cream-2/40 px-3 py-2">
<summary className="cursor-pointer flex items-center gap-1.5 text-[12px] font-semibold text-ink-2 list-none">
<Info size={12} className="text-ink-3" />
Variables que l'IA peut insérer (sans obligation)
</summary>
<ul className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-0.5 text-[11.5px] text-ink-3">
{TEMPLATE_VARIABLES.map((v) => (
<li key={v.token} className="truncate">
<code className="font-mono text-[11px] text-rubis-deep">
{v.token}
</code>{" "}
· {v.label}
</li>
))}
</ul>
<p className="mt-2 text-[11px] text-ink-3 italic leading-snug">
L'IA en utilisera une partie selon le sens du message pas toutes.
{" "}
<strong className="text-ink-2">{`{{signature}}`}</strong> est
automatiquement ajouté en fin de corps.
</p>
</details>
{result && (
<div className="space-y-2">
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">

View File

@ -18,7 +18,8 @@ const generateSchema = z.object({
tone: z.enum(RELANCE_TONES),
offsetDays: z.number().int().min(-30).max(180),
prompt: z.string().max(1000).optional(),
planContext: z.string().max(500).optional(),
planName: z.string().max(80).optional(),
planDescription: z.string().max(500).optional(),
});
/**

View File

@ -739,7 +739,8 @@ function StepMessages({
onOpenChange={setAiOpen}
tone={selected.tone}
offsetDays={selected.offsetDays}
planContext={`Plan "${draft.name}", étape ${selectedIdx + 1}/${draft.steps.length}.`}
planName={draft.name}
planDescription={draft.description}
onAccept={({ subject, body }) => updateSelected({ subject, body })}
/>
)}