Project Files
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.`;
}