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:
parent
0a3b8523ef
commit
5c7dbc2eba
@ -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) {
|
||||||
|
|||||||
@ -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}}," où 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',
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user