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 // Brief libre. On accepte vide : Mistral génère alors un message standard
// pour la tonalité + timing donnés. // pour la tonalité + timing donnés.
prompt: vine.string().maxLength(1000).optional(), 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, tone: payload.tone,
offsetDays: payload.offsetDays, offsetDays: payload.offsetDays,
prompt: payload.prompt ?? '', prompt: payload.prompt ?? '',
planContext: payload.planContext, planName: payload.planName,
planDescription: payload.planDescription,
}) })
return response.json({ data: result }) return response.json({ data: result })
} catch (err) { } catch (err) {

View File

@ -15,8 +15,10 @@ export type GenerateRelanceInput = {
offsetDays: number offsetDays: number
/** Brief libre rédigé par l'utilisateur (ex. "rappelle qu'on accepte les virements"). */ /** Brief libre rédigé par l'utilisateur (ex. "rappelle qu'on accepte les virements"). */
prompt: string prompt: string
/** Contexte du plan, pour cohérence (ex. nom du plan, étapes voisines). */ /** Nom du plan parent — donne du contexte au modèle (ex. "Clients fidèles"). */
planContext?: string planName?: string
/** Description du plan parent — quand utiliser ce plan, ICP visé. */
planDescription?: string
} }
export type GenerateRelanceOutput = { 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.", "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 en français.
- Toujours tutoyer le **destinataire** ? NON, vouvoie systématiquement (B2B France). - Vouvoie systématiquement le destinataire (B2B France).
- Reste concis : 4 à 8 phrases maximum pour le corps. - Concis : 4 à 8 phrases maximum pour le corps.
- Ne saute jamais de salutation ni de signature. - 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).
- 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,").
Variables disponibles à insérer (utilise la syntaxe Mustache exacte) : # Syntaxe des variables IMPORTANT
- {{client.name}} : raison sociale du client - Utilise UNIQUEMENT la substitution simple \`{{nom.de.variable}}\`.
- {{client.contactFirstName}} : prénom du contact (peut être vide fallback "Bonjour,") - 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) - {{client.contactLastName}} : nom du contact (peut être vide)
- {{numero}} : numéro de la facture - {{numero}} : numéro de la facture
- {{amount}} : montant TTC formaté ("1 240,00 €") - {{amount}} : montant TTC formaté (ex. "1 240,00 €")
- {{dueDate}} : date d'échéance ("15/04/2026") - {{dueDate}} : date d'échéance (ex. "15/04/2026")
- {{issueDate}} : date d'émission - {{issueDate}} : date d'émission
- {{daysLate}} : nombre de jours de retard (entier) - {{daysLate}} : jours de retard (entier peut être négatif si la relance est avant échéance)
- {{user.fullName}} : nom de l'expéditeur (la TPE) - {{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 - {{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 }` * 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.') 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 = [ const userMessage = [
`Tonalité ciblée : ${input.tone}${TONE_GUIDANCE[input.tone]}`, '# Plan parent',
`Position dans le plan : J${input.offsetDays >= 0 ? '+' : ''}${input.offsetDays} (${ input.planName ? `Nom : ${input.planName}` : 'Nom : (non précisé)',
input.offsetDays < 0 input.planDescription
? "rappel avant l'échéance" ? `Description : ${input.planDescription}`
: input.offsetDays === 0 : 'Description : (aucune)',
? 'jour de l\'échéance'
: `${input.offsetDays} jours après l'échéance`
}).`,
input.planContext ? `Contexte du plan : ${input.planContext}` : null,
'', '',
'Brief de l\'utilisateur :', '# Cette relance',
input.prompt.trim() || '(aucun brief — génère un message standard pour cette tonalité et ce timing)', `Tonalité : ${input.tone}${TONE_GUIDANCE[input.tone]}`,
] `Timing : J${input.offsetDays >= 0 ? '+' : ''}${input.offsetDays}${offsetExplanation}.`,
.filter(Boolean) '',
.join('\n') "# 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`, { const res = await fetch(`${MISTRAL_API}/chat/completions`, {
method: 'POST', method: 'POST',

View File

@ -1,11 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Sparkles, RefreshCw } from "lucide-react"; import { Sparkles, RefreshCw, Info } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { RelanceTone } from "@rubis/shared"; import type { RelanceTone } from "@rubis/shared";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { TONE_LABELS } from "@/lib/plans"; import { TONE_LABELS, TEMPLATE_VARIABLES } from "@/lib/plans";
import { import {
Dialog, Dialog,
DialogContent, 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. * 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({ export function AiGenerateModal({
open, open,
onOpenChange, onOpenChange,
tone, tone,
offsetDays, offsetDays,
planContext, planName,
planDescription,
onAccept, onAccept,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
tone: RelanceTone; tone: RelanceTone;
offsetDays: number; offsetDays: number;
planContext?: string; planName?: string;
planDescription?: string;
onAccept: (result: GenerateResult) => void; onAccept: (result: GenerateResult) => void;
}) { }) {
const [prompt, setPrompt] = useState(DEFAULT_PROMPTS[tone]); const [prompt, setPrompt] = useState(DEFAULT_PROMPTS[tone]);
const [result, setResult] = useState<GenerateResult | null>(null); const [result, setResult] = useState<GenerateResult | null>(null);
// Reset à chaque ouverture pour repartir d'un état propre.
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setPrompt(DEFAULT_PROMPTS[tone]); setPrompt(DEFAULT_PROMPTS[tone]);
@ -69,7 +72,8 @@ export function AiGenerateModal({
tone, tone,
offsetDays, offsetDays,
prompt, prompt,
planContext, planName,
planDescription,
}), }),
onSuccess: (data) => setResult(data), onSuccess: (data) => setResult(data),
onError: () => toast.error("Génération impossible. Réessayez dans un instant."), onError: () => toast.error("Génération impossible. Réessayez dans un instant."),
@ -85,8 +89,10 @@ export function AiGenerateModal({
</span> </span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
L'IA va rédiger un email de relance avec une tonalité{" "} Pour le plan{" "}
<strong>{TONE_LABELS[tone].toLowerCase()}</strong>, programmé J <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 >= 0 ? "+" : ""}
{offsetDays}. Affinez le brief si besoin. {offsetDays}. Affinez le brief si besoin.
</DialogDescription> </DialogDescription>
@ -96,7 +102,7 @@ export function AiGenerateModal({
<Field <Field
label="Brief pour l'IA" label="Brief pour l'IA"
htmlFor="ai-prompt" 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 <Textarea
id="ai-prompt" id="ai-prompt"
@ -107,6 +113,29 @@ export function AiGenerateModal({
/> />
</Field> </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 && ( {result && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold"> <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), tone: z.enum(RELANCE_TONES),
offsetDays: z.number().int().min(-30).max(180), offsetDays: z.number().int().min(-30).max(180),
prompt: z.string().max(1000).optional(), 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} onOpenChange={setAiOpen}
tone={selected.tone} tone={selected.tone}
offsetDays={selected.offsetDays} 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 })} onAccept={({ subject, body }) => updateSelected({ subject, body })}
/> />
)} )}