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
|
||||
// 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) {
|
||||
|
||||
@ -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}}," 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<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',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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 })}
|
||||
/>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user