src / verifier.ts
src / verifier.ts
import type { Citation } from "./corpus";
export function norm(s: string): string {
return s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[«»“”‘’'`]/g, "")
.replace(/[.,;:!?…\-—–]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function lev(a: string, b: string): number {
const m = a.length, n = b.length;
if (!m) return n ? 1 : 0;
if (!n) return 1;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => [i]);
for (let j = 1; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n] / Math.max(m, n);
}
// B1 — couvre les guillemets typographiques français («»), anglais courbes
// (“” et ‘’), et droits ASCII (""). Les chatbots (notamment macOS et Qwen)
// produisent souvent les courbes plutôt que les droits — sans cette
// alternative, ces citations passaient à travers le vérificateur en
// silence.
const QUOTE_PATTERNS = [
/«\s*([^«»]{15,400})\s*»/g,
/[“”]([^“”]{15,400})[“”]/g,
/"([^"]{15,400})"/g,
];
const AUTHOR_RE = /\b(Platon|Aristote|Épicure|Épictète|Augustin|Descartes|Pascal|Locke|Spinoza|Leibniz|Hobbes|Rousseau|Hume|Kant|Hegel|Schopenhauer|Tocqueville|Mill|Marx|Nietzsche|Freud|Bergson|Husserl|Heidegger|Wittgenstein|Sartre|Arendt|Beauvoir|Levinas|Foucault|Rawls|Lacan|Deleuze|Derrida|Ricoeur|Ricœur|Bachelard|Popper|Jonas|Simondon|Weil)\b/i;
export type Quote = {
raw: string;
normalized: string;
attributedTo: string | null;
attributionPosition: "before" | "after" | null;
index: number;
};
function scanBefore(text: string, quoteStart: number): string | null {
// Pattern français standard : "Comme l'écrit X : « ... »" — auteur AVANT la citation.
const beforeStart = Math.max(0, quoteStart - 120);
let before = text.slice(beforeStart, quoteStart);
const cuts = [
before.lastIndexOf(". "), before.lastIndexOf("! "), before.lastIndexOf("? "),
before.lastIndexOf(".\n"), before.lastIndexOf("!\n"), before.lastIndexOf("?\n"),
before.lastIndexOf("\n\n"), before.lastIndexOf("\n- "), before.lastIndexOf("\n* "),
// Aussi : coupe sur un item de liste numérotée "\n1. " "\n2. " etc.
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => before.lastIndexOf(`\n${n}. `)),
];
const cut = Math.max(...cuts);
if (cut >= 0) before = before.slice(cut + 1);
// B2 — on prend le DERNIER auteur mentionné avant la citation, pas le
// premier. Pour « Comme l'a montré Kant, et bien avant lui Spinoza : "…" »
// l'attribution réelle est Spinoza. AUTHOR_RE est global → on collecte
// tous les hits et on prend le dernier.
const matches = [...before.matchAll(new RegExp(AUTHOR_RE.source, "gi"))];
if (matches.length === 0) return null;
return matches[matches.length - 1][1];
}
function scanAfter(text: string, quoteEnd: number): string | null {
// Pattern liste / bibliographique : « ... » — Auteur, dans Œuvre.
// Aussi : « ... » (Auteur, Œuvre). Auteur APRÈS la citation.
let after = text.slice(quoteEnd, Math.min(text.length, quoteEnd + 120));
// Coupe au début de la phrase / item / citation suivante pour ne pas pomper
// l'auteur d'une citation voisine.
const cuts = [
after.indexOf("\n\n"), after.indexOf("\n- "), after.indexOf("\n* "),
after.indexOf(" « "), after.indexOf("«"), // une nouvelle citation commence
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => after.indexOf(`\n${n}. `)),
].filter((i) => i > 0);
if (cuts.length > 0) after = after.slice(0, Math.min(...cuts));
// Heuristique : on cherche l'auteur dans un contexte "— X" ou "(X" ou ", X,"
// — pour éviter de capter un auteur cité incidemment dans la prose qui suit.
// En pratique : on prend juste le premier AUTHOR_RE match dans la fenêtre coupée.
const m = after.match(AUTHOR_RE);
return m ? m[1] : null;
}
function detectAttribution(text: string, quoteStart: number, quoteEnd: number): { name: string; pos: "before" | "after" } | null {
const b = scanBefore(text, quoteStart);
if (b) return { name: b, pos: "before" };
const a = scanAfter(text, quoteEnd);
if (a) return { name: a, pos: "after" };
return null;
}
export function extractQuotes(text: string): Quote[] {
const found: Quote[] = [];
for (const re of QUOTE_PATTERNS) {
for (const m of text.matchAll(re)) {
const raw = m[1].trim();
if (!raw) continue;
const start = m.index!;
const end = start + m[0].length;
const attr = detectAttribution(text, start, end);
found.push({
raw,
normalized: norm(raw),
attributedTo: attr ? attr.name : null,
attributionPosition: attr ? attr.pos : null,
index: start,
});
}
}
const seen = new Set<string>();
return found.filter((q) => (seen.has(q.normalized) ? false : (seen.add(q.normalized), true)));
}
// Seuil de distance Levenshtein normalisée en-dessous duquel deux phrases
// sont considérées comme la même citation. Partagé avec corpus-builder
// (verifyQuoteAtUrl) pour que le narrateur et le vérificateur externe
// utilisent la même barre.
export const MATCH_THRESHOLD = 0.18;
export type FaultyQuote = {
quote: Quote;
reason: "no_match" | "attribution_mismatch";
bestDist: number;
attributedTo?: string | null;
actualAuteur?: string;
};
export type QuoteAudit = {
raw: string;
attributedTo: string | null;
attributionPosition: "before" | "after" | null;
status: "matched" | "faulty" | "skipped";
matchedTo?: { id: string; auteur: string; oeuvre: string };
bestDist?: number;
faultyReason?: "no_match" | "attribution_mismatch";
actualAuteur?: string;
};
export function verify(text: string, corpus: Citation[]) {
const quotes = extractQuotes(text);
const matched: Quote[] = [];
const faulty: FaultyQuote[] = [];
const audits: QuoteAudit[] = [];
for (const q of quotes) {
let best: Citation | null = null;
let bestDist = 1.0;
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) {
if (q.attributedTo == null) {
// Pas d'auteur du programme bac détecté autour → quote considérée décorative.
audits.push({
raw: q.raw, attributedTo: null, attributionPosition: null,
status: "skipped", bestDist,
});
continue;
}
const fq: FaultyQuote = { quote: q, reason: "no_match", bestDist };
faulty.push(fq);
audits.push({
raw: q.raw, attributedTo: q.attributedTo, attributionPosition: q.attributionPosition,
status: "faulty", bestDist, faultyReason: "no_match",
});
continue;
}
if (q.attributedTo) {
const attr = norm(q.attributedTo);
const canon = norm(best.auteur_slug || "");
const last = norm(best.auteur.split(/\s+/).slice(-1)[0]);
if (attr !== canon && attr !== last) {
const fq: FaultyQuote = {
quote: q, reason: "attribution_mismatch", bestDist,
attributedTo: q.attributedTo, actualAuteur: best.auteur,
};
faulty.push(fq);
audits.push({
raw: q.raw, attributedTo: q.attributedTo, attributionPosition: q.attributionPosition,
status: "faulty", bestDist, faultyReason: "attribution_mismatch", actualAuteur: best.auteur,
matchedTo: { id: best.id, auteur: best.auteur, oeuvre: best.oeuvre },
});
continue;
}
}
matched.push(q);
audits.push({
raw: q.raw, attributedTo: q.attributedTo, attributionPosition: q.attributionPosition,
status: "matched", bestDist,
matchedTo: { id: best.id, auteur: best.auteur, oeuvre: best.oeuvre },
});
}
return {
ok: faulty.length === 0,
total: quotes.length,
matched: matched.length,
faulty,
audits,
};
}
export function buildCorrectivePrompt(faulty: FaultyQuote[]): string {
const lines = faulty.map((f) => {
const reason = f.reason === "attribution_mismatch"
? `cette citation est en réalité de ${f.actualAuteur}, pas de ${f.attributedTo}. Corrige l'attribution ou retire la citation.`
: `cette citation ne figure PAS dans le corpus autorisé. Retire-la ou transforme-la en paraphrase sans guillemets, introduite par « comme le suggère X » ou « dans la lignée de Y ».`;
return ` - « ${f.quote.raw.slice(0, 120)}${f.quote.raw.length > 120 ? "…" : ""} »\n → ${reason}`;
}).join("\n");
return `[VÉRIFICATEUR DE CITATIONS] STOP. Ta dernière réponse contient des citations non conformes :
${lines}
RÈGLE TYPOGRAPHIQUE STRICTE pour les paraphrases :
- ❌ INTERDIT : « Dans la lignée de Kant : "La liberté est..." » — pas de guillemets autour d'une paraphrase.
- ✅ CORRECT : Dans la lignée de Kant, on peut affirmer que la liberté joue un rôle central.
Réécris UNIQUEMENT ta dernière réponse, dans le même format, en ne corrigeant QUE les citations problématiques. Pas de méta-commentaire. Réponds directement avec la version corrigée.`;
}
import type { Citation } from "./corpus";
export function norm(s: string): string {
return s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[«»“”‘’'`]/g, "")
.replace(/[.,;:!?…\-—–]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function lev(a: string, b: string): number {
const m = a.length, n = b.length;
if (!m) return n ? 1 : 0;
if (!n) return 1;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => [i]);
for (let j = 1; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n] / Math.max(m, n);
}
// B1 — couvre les guillemets typographiques français («»), anglais courbes
// (“” et ‘’), et droits ASCII (""). Les chatbots (notamment macOS et Qwen)
// produisent souvent les courbes plutôt que les droits — sans cette
// alternative, ces citations passaient à travers le vérificateur en
// silence.
const QUOTE_PATTERNS = [
/«\s*([^«»]{15,400})\s*»/g,
/[“”]([^“”]{15,400})[“”]/g,
/"([^"]{15,400})"/g,
];
const AUTHOR_RE = /\b(Platon|Aristote|Épicure|Épictète|Augustin|Descartes|Pascal|Locke|Spinoza|Leibniz|Hobbes|Rousseau|Hume|Kant|Hegel|Schopenhauer|Tocqueville|Mill|Marx|Nietzsche|Freud|Bergson|Husserl|Heidegger|Wittgenstein|Sartre|Arendt|Beauvoir|Levinas|Foucault|Rawls|Lacan|Deleuze|Derrida|Ricoeur|Ricœur|Bachelard|Popper|Jonas|Simondon|Weil)\b/i;
export type Quote = {
raw: string;
normalized: string;
attributedTo: string | null;
attributionPosition: "before" | "after" | null;
index: number;
};
function scanBefore(text: string, quoteStart: number): string | null {
// Pattern français standard : "Comme l'écrit X : « ... »" — auteur AVANT la citation.
const beforeStart = Math.max(0, quoteStart - 120);
let before = text.slice(beforeStart, quoteStart);
const cuts = [
before.lastIndexOf(". "), before.lastIndexOf("! "), before.lastIndexOf("? "),
before.lastIndexOf(".\n"), before.lastIndexOf("!\n"), before.lastIndexOf("?\n"),
before.lastIndexOf("\n\n"), before.lastIndexOf("\n- "), before.lastIndexOf("\n* "),
// Aussi : coupe sur un item de liste numérotée "\n1. " "\n2. " etc.
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => before.lastIndexOf(`\n${n}. `)),
];
const cut = Math.max(...cuts);
if (cut >= 0) before = before.slice(cut + 1);
// B2 — on prend le DERNIER auteur mentionné avant la citation, pas le
// premier. Pour « Comme l'a montré Kant, et bien avant lui Spinoza : "…" »
// l'attribution réelle est Spinoza. AUTHOR_RE est global → on collecte
// tous les hits et on prend le dernier.
const matches = [...before.matchAll(new RegExp(AUTHOR_RE.source, "gi"))];
if (matches.length === 0) return null;
return matches[matches.length - 1][1];
}
function scanAfter(text: string, quoteEnd: number): string | null {
// Pattern liste / bibliographique : « ... » — Auteur, dans Œuvre.
// Aussi : « ... » (Auteur, Œuvre). Auteur APRÈS la citation.
let after = text.slice(quoteEnd, Math.min(text.length, quoteEnd + 120));
// Coupe au début de la phrase / item / citation suivante pour ne pas pomper
// l'auteur d'une citation voisine.
const cuts = [
after.indexOf("\n\n"), after.indexOf("\n- "), after.indexOf("\n* "),
after.indexOf(" « "), after.indexOf("«"), // une nouvelle citation commence
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => after.indexOf(`\n${n}. `)),
].filter((i) => i > 0);
if (cuts.length > 0) after = after.slice(0, Math.min(...cuts));
// Heuristique : on cherche l'auteur dans un contexte "— X" ou "(X" ou ", X,"
// — pour éviter de capter un auteur cité incidemment dans la prose qui suit.
// En pratique : on prend juste le premier AUTHOR_RE match dans la fenêtre coupée.
const m = after.match(AUTHOR_RE);
return m ? m[1] : null;
}
function detectAttribution(text: string, quoteStart: number, quoteEnd: number): { name: string; pos: "before" | "after" } | null {
const b = scanBefore(text, quoteStart);
if (b) return { name: b, pos: "before" };
const a = scanAfter(text, quoteEnd);
if (a) return { name: a, pos: "after" };
return null;
}
export function extractQuotes(text: string): Quote[] {
const found: Quote[] = [];
for (const re of QUOTE_PATTERNS) {
for (const m of text.matchAll(re)) {
const raw = m[1].trim();
if (!raw) continue;
const start = m.index!;
const end = start + m[0].length;
const attr = detectAttribution(text, start, end);
found.push({
raw,
normalized: norm(raw),
attributedTo: attr ? attr.name : null,
attributionPosition: attr ? attr.pos : null,
index: start,
});
}
}
const seen = new Set<string>();
return found.filter((q) => (seen.has(q.normalized) ? false : (seen.add(q.normalized), true)));
}
// Seuil de distance Levenshtein normalisée en-dessous duquel deux phrases
// sont considérées comme la même citation. Partagé avec corpus-builder
// (verifyQuoteAtUrl) pour que le narrateur et le vérificateur externe
// utilisent la même barre.
export const MATCH_THRESHOLD = 0.18;
export type FaultyQuote = {
quote: Quote;
reason: "no_match" | "attribution_mismatch";
bestDist: number;
attributedTo?: string | null;
actualAuteur?: string;
};
export type QuoteAudit = {
raw: string;
attributedTo: string | null;
attributionPosition: "before" | "after" | null;
status: "matched" | "faulty" | "skipped";
matchedTo?: { id: string; auteur: string; oeuvre: string };
bestDist?: number;
faultyReason?: "no_match" | "attribution_mismatch";
actualAuteur?: string;
};
export function verify(text: string, corpus: Citation[]) {
const quotes = extractQuotes(text);
const matched: Quote[] = [];
const faulty: FaultyQuote[] = [];
const audits: QuoteAudit[] = [];
for (const q of quotes) {
let best: Citation | null = null;
let bestDist = 1.0;
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) {
if (q.attributedTo == null) {
// Pas d'auteur du programme bac détecté autour → quote considérée décorative.
audits.push({
raw: q.raw, attributedTo: null, attributionPosition: null,
status: "skipped", bestDist,
});
continue;
}
const fq: FaultyQuote = { quote: q, reason: "no_match", bestDist };
faulty.push(fq);
audits.push({
raw: q.raw, attributedTo: q.attributedTo, attributionPosition: q.attributionPosition,
status: "faulty", bestDist, faultyReason: "no_match",
});
continue;
}
if (q.attributedTo) {
const attr = norm(q.attributedTo);
const canon = norm(best.auteur_slug || "");
const last = norm(best.auteur.split(/\s+/).slice(-1)[0]);
if (attr !== canon && attr !== last) {
const fq: FaultyQuote = {
quote: q, reason: "attribution_mismatch", bestDist,
attributedTo: q.attributedTo, actualAuteur: best.auteur,
};
faulty.push(fq);
audits.push({
raw: q.raw, attributedTo: q.attributedTo, attributionPosition: q.attributionPosition,
status: "faulty", bestDist, faultyReason: "attribution_mismatch", actualAuteur: best.auteur,
matchedTo: { id: best.id, auteur: best.auteur, oeuvre: best.oeuvre },
});
continue;
}
}
matched.push(q);
audits.push({
raw: q.raw, attributedTo: q.attributedTo, attributionPosition: q.attributionPosition,
status: "matched", bestDist,
matchedTo: { id: best.id, auteur: best.auteur, oeuvre: best.oeuvre },
});
}
return {
ok: faulty.length === 0,
total: quotes.length,
matched: matched.length,
faulty,
audits,
};
}
export function buildCorrectivePrompt(faulty: FaultyQuote[]): string {
const lines = faulty.map((f) => {
const reason = f.reason === "attribution_mismatch"
? `cette citation est en réalité de ${f.actualAuteur}, pas de ${f.attributedTo}. Corrige l'attribution ou retire la citation.`
: `cette citation ne figure PAS dans le corpus autorisé. Retire-la ou transforme-la en paraphrase sans guillemets, introduite par « comme le suggère X » ou « dans la lignée de Y ».`;
return ` - « ${f.quote.raw.slice(0, 120)}${f.quote.raw.length > 120 ? "…" : ""} »\n → ${reason}`;
}).join("\n");
return `[VÉRIFICATEUR DE CITATIONS] STOP. Ta dernière réponse contient des citations non conformes :
${lines}
RÈGLE TYPOGRAPHIQUE STRICTE pour les paraphrases :
- ❌ INTERDIT : « Dans la lignée de Kant : "La liberté est..." » — pas de guillemets autour d'une paraphrase.
- ✅ CORRECT : Dans la lignée de Kant, on peut affirmer que la liberté joue un rôle central.
Réécris UNIQUEMENT ta dernière réponse, dans le même format, en ne corrigeant QUE les citations problématiques. Pas de méta-commentaire. Réponds directement avec la version corrigée.`;
}