Project Files
src / skeleton-validator.ts
// Validation pédagogique du squelette de dissertation.
//
// Le validateur de citations (verifier.ts) garantit qu'aucune citation
// hallucinée ne sort. Mais il ne peut PAS détecter qu'une citation du
// corpus est mal *utilisée* sémantiquement — par exemple Rousseau
// « né libre, partout dans les fers » utilisée pour défendre une thèse
// alors qu'elle problématise.
//
// Ce module attrape les misuses connus + impose la diversité des
// auteurs (un correcteur bac pénalise la mono-culture). Pattern
// validate-and-retry comme le reste : si violation, on rejette et on
// demande au modèle de réécrire.
import type { Citation } from "./corpus";
import { extractQuotes, norm, lev, MATCH_THRESHOLD } from "./verifier";
// ----- Misuses connus -----
const FORBIDDEN_IN_DEVELOPMENT_PARTS: Record<string, string> = {
"rousseau-contrat-social-1-1":
"Cette citation « L'homme est né libre, et partout il est dans les fers » est l'INCIPIT qui POSE le problème de la dissertation. Elle est destinée à la problématique ou à l'introduction, pas à une partie de développement. L'y placer est un contresens classique pénalisé au bac : Rousseau ne défend ni la liberté naturelle ni l'autonomie ici, il signale une tension à élucider. Pour une partie sur l'autonomie morale, utilise plutôt Rousseau « L'obéissance à la loi qu'on s'est prescrite est liberté » (Contrat social I, 8), qui est dans le corpus.",
"descartes-meditation-4-indifference-plus-bas-degre":
"Descartes dit dans cette citation que l'indifférence est le PLUS BAS degré de la liberté — donc il la DÉVALORISE. L'utiliser pour défendre la liberté comme choix arbitraire ou non motivé est un contresens. Cette citation ne marche que dans une partie qui critique la conception vulgaire de la liberté comme caprice.",
};
// ----- Parsing du squelette -----
export type SkeletonSection = {
kind: "problematique" | "part" | "intro" | "other";
index?: number;
title: string;
start: number;
end: number;
};
export type MatchedCitation = {
corpusId: string;
auteur: string;
quoteRaw: string;
sectionKind: SkeletonSection["kind"];
sectionIndex?: number;
sectionTitle: string;
};
// Heuristique pour reconnaître qu'un tour EST un squelette (et non une
// question/réponse libre après le climax). Évite de déclencher le
// validator sur tous les tours post-narratifs.
export function hasSkeletonStructure(content: string): boolean {
const hasProblematique = /\*\*\s*Probl[ée]matique\s*\*\*/i.test(content);
const hasNumberedPart = /(^|\n)\s*\d+\.\s/.test(content);
return hasProblematique && hasNumberedPart;
}
export function parseSkeleton(content: string): SkeletonSection[] {
const sections: SkeletonSection[] = [];
const problemRe = /\*\*\s*Probl[ée]matique\s*\*\*/i;
const problemMatch = content.match(problemRe);
const partRe = /(?:^|\n)\s*(\d+)\.\s+(\**\s*[^\n*]+?\s*\**)\s*\n/g;
const partMatches: Array<{ idx: number; num: number; title: string }> = [];
let m: RegExpExecArray | null;
while ((m = partRe.exec(content)) !== null) {
partMatches.push({
idx: m.index + (m[0].startsWith("\n") ? 1 : 0),
num: parseInt(m[1], 10),
title: m[2].replace(/\*+/g, "").trim(),
});
}
if (problemMatch && problemMatch.index !== undefined) {
const start = problemMatch.index;
const end = partMatches.length > 0 ? partMatches[0].idx : content.length;
sections.push({ kind: "problematique", title: "Problématique", start, end });
}
for (let i = 0; i < partMatches.length; i++) {
const cur = partMatches[i];
const start = cur.idx;
const end = i + 1 < partMatches.length ? partMatches[i + 1].idx : content.length;
sections.push({ kind: "part", index: cur.num, title: cur.title, start, end });
}
return sections;
}
export function attachCitations(
content: string,
sections: SkeletonSection[],
corpus: Citation[],
): MatchedCitation[] {
const quotes = extractQuotes(content);
const matched: MatchedCitation[] = [];
for (const q of quotes) {
let best: Citation | null = null;
let bestDist = 1;
for (const c of corpus) {
const candidates = [c.texte_fr, ...(c.aliases || [])].filter(Boolean);
for (const cand of candidates) {
const d = lev(q.normalized, norm(cand));
if (d < bestDist) { bestDist = d; best = c; }
}
}
if (!best || bestDist > MATCH_THRESHOLD) continue;
const section = sections.find((s) => q.index >= s.start && q.index < s.end);
matched.push({
corpusId: best.id,
auteur: best.auteur,
quoteRaw: q.raw,
sectionKind: section?.kind ?? "other",
sectionIndex: section?.index,
sectionTitle: section?.title ?? "(hors section identifiée)",
});
}
return matched;
}
// ----- Violations -----
export type SkeletonViolation =
| {
kind: "forbidden_citation_in_part";
corpusId: string;
auteur: string;
partIndex: number;
partTitle: string;
reason: string;
}
| {
kind: "author_repeated_across_parts";
auteur: string;
partIndices: number[];
};
export function validateSkeleton(
content: string,
corpus: Citation[],
): { violations: SkeletonViolation[]; matched: MatchedCitation[] } {
if (!hasSkeletonStructure(content)) return { violations: [], matched: [] };
const sections = parseSkeleton(content);
const matched = attachCitations(content, sections, corpus);
const violations: SkeletonViolation[] = [];
for (const cit of matched) {
if (cit.sectionKind !== "part" || cit.sectionIndex == null) continue;
const reason = FORBIDDEN_IN_DEVELOPMENT_PARTS[cit.corpusId];
if (reason) {
violations.push({
kind: "forbidden_citation_in_part",
corpusId: cit.corpusId,
auteur: cit.auteur,
partIndex: cit.sectionIndex,
partTitle: cit.sectionTitle,
reason,
});
}
}
const authorToParts = new Map<string, Set<number>>();
for (const cit of matched) {
if (cit.sectionKind !== "part" || cit.sectionIndex == null) continue;
if (!authorToParts.has(cit.auteur)) authorToParts.set(cit.auteur, new Set());
authorToParts.get(cit.auteur)!.add(cit.sectionIndex);
}
for (const [auteur, partsSet] of authorToParts) {
const parts = [...partsSet].sort((a, b) => a - b);
if (parts.length > 1) {
violations.push({ kind: "author_repeated_across_parts", auteur, partIndices: parts });
}
}
return { violations, matched };
}
// ----- Corrective prompt -----
export function buildSkeletonCorrective(violations: SkeletonViolation[]): string {
if (violations.length === 0) return "";
const lines: string[] = [
"[VÉRIFICATEUR DE SQUELETTE] STOP. Ton squelette contient des problèmes d'alignement pédagogique :",
"",
];
for (const v of violations) {
if (v.kind === "forbidden_citation_in_part") {
lines.push(`• **Partie ${v.partIndex} (« ${v.partTitle} »)** — citation de ${v.auteur} mal placée :`);
lines.push(` ${v.reason}`);
lines.push("");
} else {
lines.push(`• **${v.auteur}** est cité dans plusieurs parties (${v.partIndices.join(" et ")}). Un plan dialectique doit mobiliser des AUTEURS DISTINCTS pour démontrer la culture philosophique. Remplace une des occurrences par une citation d'un AUTRE auteur du corpus autorisé.`);
lines.push("");
}
}
lines.push("Réécris UNIQUEMENT le squelette de dissertation, sans préambule ni excuse :");
lines.push("- Garde la problématique et la structure (2-3 parties).");
lines.push("- Remplace les citations problématiques par d'autres du corpus qui SOUTIENNENT effectivement le titre de leur partie.");
lines.push("- Mobilise un AUTEUR DIFFÉRENT par partie (jamais deux fois le même auteur, même œuvre différente).");
lines.push("- Re-respecte la règle de transposition (auteur différent de celui cité dans la partie).");
return lines.join("\n");
}
export function skeletonViolationSummary(v: SkeletonViolation): string {
switch (v.kind) {
case "forbidden_citation_in_part":
return `citation ${v.auteur} mal placée en partie ${v.partIndex} (« ${v.partTitle.slice(0, 40)}… »)`;
case "author_repeated_across_parts":
return `auteur ${v.auteur} répété dans parties ${v.partIndices.join("/")}`;
}
}