PayFit
01 / 22
Devoxx France 2026

Du texte aux données structurées complexes

Quand le prompt ne suffit plus

Thomas Villaren Senior Staff Engineer @ PayFit

Du texte aux données structurées complexes

Quand le prompt ne suffit plus

Thomas Villaren

Thomas Villaren

Senior Staff Software Engineer
SMIC — Salaire Minimum Interprofessionnel de Croissance
12,02 €/h
Mensuel brut 1 823,03 €/mois
Salaire Minimum

Convention Syntec

ETAM (8 classifications)

PositionCoefficientSMC mensuel
1.12401 815,00 €
1.22501 845,00 €
2.12751 875,00 €
2.23101 905,00 €
2.33552 045,00 €
3.14002 185,00 €
3.24502 340,00 €
3.35002 490,00 €

Ingénieurs et Cadres (9 classifications)

PositionCoefficientSMC mensuel
1.1952 135,00 €
1.21002 240,00 €
2.11052 315,00 €
2.11152 530,00 €
2.21302 850,00 €
2.31503 275,00 €
3.11703 650,00 €
3.22104 495,00 €
3.32705 755,00 €

Du texte aux données structurées

ETAM (8)

Pos.Coef.SMC
1.12401 815 €
1.22501 845 €
2.12751 875 €
2.23101 905 €
2.33552 045 €
3.14002 185 €
3.24502 340 €
3.35002 490 €

Cadres (9)

Pos.Coef.SMC
1.1952 135 €
1.21002 240 €
2.11052 315 €
2.11152 530 €
2.21302 850 €
2.31503 275 €
3.11703 650 €
3.22104 495 €
3.32705 755 €
[
  { "classifications": ["ETAM", "1.1", "240"],   "monthlyAmount": 1815 },
  { "classifications": ["Cadres", "1.1", "95"],  "monthlyAmount": 2135 },
  ...
  { "classifications": ["Cadres", "3.3", "270"], "monthlyAmount": 5755 }
]
Notre approche en 3 étapes
1

Structured Output

101 — Le prompt

2

Feedback Loop

Quand le prompt ne suffit plus

3

Évaluation

Non-déterministe ≠ Non-testable

01

Structured Output

101 — Le prompt

Structure d'un prompt

System Prompt

  • Qui suis-je ?
  • Schéma cible (ex. TypeScript, JSON Schema)
  • Règles métier spécifiques
  • Exemples
  • Instructions sur le format d'output

User Prompt

  • Injection du contexte, candidat au prompt caching
  • Instructions propres à la tâche courante
// appel au service LLM
const result = await llmService.callLLM({
  systemPrompt,
  userPrompt,
});
SYSTEM (template)
<context>
You're a French expert on labor and payroll law. You receive an excerpt of a law text (arrêté or avenant) updating the minimum wages (Salaire Minimum Conventionnel, SMC) 
of a convention collective nationale (CCN).
You need to extract those updates as structured JSON.
</context>
Your output MUST be a JSON array of MinimumWageUpdate operations matching 
the following type:

type MinimumWageUpdate = {
  type: 'minimumWage';
  classification: string[];
  minimumWage: MinimumWage;
  applicabilityDate: {
    date?: string;
    firstDayOfMonthAfterPublication?: boolean;
  };
};

type MinimumWage =
  | MonthlyMinimumWage
  | HourlyMinimumWage;

type MonthlyMinimumWage = {
  type: 'monthly';
  monthlyAmount: number;
};

type HourlyMinimumWage = {
  type: 'hourly';
  hourlyRate: number;
};
<classificationLevels>
You MUST use the existing level values (between quotes) as provided in the user prompt.
YOU MUST ALWAYS USE THE FULL PATH of the classification levels.

  <warning>
  When the text includes "emploi repères" or job titles,
  do NOT use these to restrict the categories to update.
  </warning>
</classificationLevels>

<minimumWageTypes>
The minimum wage can be of different types:
  - Monthly amount (forfait mensuel)    // preferred when both are present
  - Hourly rate

  <important>
  YOU MUST USE IN PRIORITY THE MONTHLY AMOUNT (FORFAIT MENSUEL) TYPE if the text mentions 
  a monthly amount for that level anywhere.
  </important>
</minimumWageTypes>

<applicabilityDate>
  Extract the applicability date in YYYY-MM-DD format. 
  If the text mentions "au plus tôt le 1er jour du mois civil suivant la date de 
  publication...", set firstDayOfMonthAfterPublication to true.
</applicabilityDate>

<rules>
- Use the EXACT CAPITALIZATION as provided in <classificationLevels> (e.g. "Ingénieurs et Cadres" with capital "C").
- If the text is not about SMC, return an empty array.
</rules>

<check>
BEFORE finalizing your answer:
  1. Verify the count matches the number of classification paths the text actually updates.
  2. Verify each path has the SAME NUMBER OF LEVELS.
  3. For each operation, confirm an EXACT MATCH in the existing classification levels.
</check>
<examples>
// Example: the text mentions coefficients only — we must
// expand each one to its FULL classification path.

// INPUT excerpt:
  "Les salaires minimums conventionnels sont fixés comme suit :
     Coefficient 240 : 1 820 €/mois
     Coefficient 250 : 1 850 €/mois
   Applicable au 1er jour du mois suivant publication."

// EXPECTED OUTPUT:
[
  {
    type: 'minimumWage',
    classification: ["ETAM", "1.1", "240"],
    minimumWage: { type: 'monthly', monthlyAmount: 1820 },
    applicabilityDate: { firstDayOfMonthAfterPublication: true }
  },
  {
    type: 'minimumWage',
    classification: ["ETAM", "1.2", "250"],
    minimumWage: { type: 'monthly', monthlyAmount: 1850 },
    applicabilityDate: { firstDayOfMonthAfterPublication: true }
  }
]
</examples>
<format>
⚠️ CRITICAL: ANSWER STRICTLY IN JSON FORMAT ONLY ⚠️
- Do not include explanations
- Do not include commentary
- Do not include any text outside the JSON array
- Your entire response must be valid JSON
</format>
USER (per-accord content)
Current CCN is #${collectiveAgreementCode}: "${collectiveAgreementName}"

<classificationLevels>
  Current classification level names are:
    Level 1: Catégorie
    Level 2: Position
    Level 3: Coefficient

  Current paths for the classification levels:
    "ETAM" > "1.1" > "240"
    "ETAM" > "1.2" > "250"
    [...]
    "Cadres" > "3.3" > "270"
</classificationLevels>
<textContent>
  <title>${accord.title}</title>
  <content>${accord.content}</content>
</textContent>

Activer le Structured Output

  • Sortie JSON valide — quasi universel
  • Garantie de respecter le schéma — natif chez les majors (Pydantic / Zod)
Schéma (Zod)
const UpdateSchema = z.object({
  type: z.literal("monthly"),
  classification: z.array(z.string()),
  monthlyAmount: z.number(),
});
OpenAI Anthropic Gemini Mistral
JSON valide response_format:
{ type: "json_object" }
output_format
(beta · schema requis)
response_mime_type:
"application/json"
response_format:
{ type: "json_object" }
(idem OpenAI)
Schéma strict response_format:
{ type: "json_schema" }
+ strict: true
output_format
+ JSON Schema · (beta)
response_schema
(OpenAPI)
response_format:
{ type: "json_schema" }
+ strict: true · ou chat.parse()
  • ⚠️ Wrappers & 3rd party sont souvent en retard 👉 "hacker" l'API en forçant l'usage d'un tool
  • ⚠️ Les patterns min/max, minLength, email... sont souvent ignorés

Le prompt ne suffit pas

📑 CCN
Grille salariale
"classification": ["Employé", "N2"],
"monthlyAmount": 1829
DB classification_levels
  • Employés & Agent de Maîtrise > N1
  • Employés & Agent de Maîtrise > N2
  • Employés & Agent de Maîtrise > N3
  • Cadre > I
niveau inconnu de la CCN
(catégorie "Employé" absente)
📑 CCN
Extrait texte
Coeff.SMC
2101 820 €
3202 450 €
[
  {
    "classification": ["Employés", "210"],
    "monthlyAmount": 1820
  },
  {
    "classification": ["Employés", "320"],
    "monthlyAmount": 2450
  }
]
DB classification_levels
  • Employés > 210
  • Employés > 320
  • Cadres > 210
  • Cadres > 320
2 classifications manquantes
(catégorie Cadres omise)
Robot scared
02

Feedback Loop

Quand le prompt ne suffit plus

Validation syntaxique & sémantique

Syntaxe

Validité structurelle du JSON

Sémantique statique

Conformité au schéma : types, champs requis, enums

Sémantique dynamique

Règles métier : bornes, formats, cohérences inter-champs

SmcExtractionService.extract()
// 1. Appel initial — syntaxe + schéma gérés par promptAndValidateJSON
let operations = await llmService.promptAndValidateJSON<MinimumWageUpdate[]>(
  systemPrompt,
  [{ role: 'user', content: initialPrompt }],
  z.array(minimumWageUpdateSchema),
);
// 2. Historique conservé pour les itérations
const messages: LLMMessages = [
  { role: 'user',      content: initialPrompt },
  { role: 'assistant', content: JSON.stringify(operations) },
];

// DB lookup
const expectedClassificationLevel: Set<string> = await classificationRepo.listPaths();

// 3. Règles métier — boucle de retry jusqu'à maxRetries
for (let i = 0; i < maxRetries; i++) {
  const uniqueClassificationPaths = new Set(
    operations.map(o => o.classification.join('>')),
  );

  // ① chaque classification renvoyée est-elle connue ?
  const unknown = uniqueClassificationPaths.difference(expectedClassificationLevel);
  // ② une opération par classification attendue ?
  const missing = uniqueClassificationPaths.size < expectedClassificationLevel.size;

  if (unknown.size === 0 && !missing) break;

  if (unknown.size > 0) {
    messages.push({ /* "unknown classification paths: [...]" */ });
  }
  if (missing) {
    messages.push({ /* feedback métier au LLM */ });
  }

  // Nouvelle passe — historique conservé
  operations = await llmService.promptAndValidateJSON(systemPrompt, messages, /* idem */);
}

return { operations, messages };

En pratique

03

Évaluation

Non-déterministe ≠ non-testable

Évaluations

Scorers
vs
LLM-as-a-judge
  • Détecter les améliorations / régressions lors d'un changement (prompt, validateurs)
  • Tester différents modèles / fournisseurs (temps de réponse vs qualité vs coût)
  • Construire un dataset de feedback pour améliorer la pipeline

Process d'évaluation / scoring

Input A Extraction via LLM
Input B Dataset existant
Étape centrale Comparaison
Output Score (true / partial / false)

Ex. log produit par le scorer

[INFO] Processing CCN: 0018 (KALITEXT000051149719)
[INFO] Extracted 31 parameter operations.
[INFO] Found 31 existing parameters in the database. Starting comparison...
[INFO] update: Ouvrier > 1 > 1        / 2025-03-05 / {"type":"monthly","monthlyAmount":1829}
[INFO] update: Employé > 1 > 1        / 2025-03-05 / {"type":"monthly","monthlyAmount":1829}
                                     [...]
[INFO] update: Cadre > IV > 1         / 2025-03-05 / {"type":"monthly","monthlyAmount":4848}
[INFO] ✅ All operations are matching one parameter in the database 🎉

Résultats d'évaluation

sur ~107 évaluations · ✓ succès · ⚠ à relire · ✗ échec

Modèle Résultats
Claude Sonnet 4.5reasoning 16k · sans validation
73,4 %16,5 %10,1 %
Claude Sonnet 4.5reasoning 16k
82,2 %13,1 %4,7 %
Claude Sonnet 4.6thinking: high
77,6 %12,1 %10,3 %
Claude Opus 4.7thinking: high · sans validation
80,2 %13,2 %6,6 %
Claude Opus 4.7thinking: high
83,2 %13,1 %3,7 %
GPT 5.4
74,1 %19,4 %6,5 %
Pixtral Large2502
26,4 %10,9 %62,7 %

Conclusion

Apprivoiser le non-déterminisme

Robot tamer
Robot scorer

Évaluation-Driven Development

Patterns everywhere

Un pattern,
mille usages

Merci !

Des questions ?

Thomas Villaren Senior Staff Engineer @ PayFit
QR villaren.fr/pubntalks/devoxx2026

villaren.fr/pubntalks/devoxx2026