From 493cf806151197437ac70266ce819d9fce65f63e Mon Sep 17 00:00:00 2001 From: mathieu Date: Tue, 18 Aug 2026 02:22:14 +0200 Subject: [PATCH] Normaliser les textes et poser les regles de rapprochement d'auteurs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite ne sait pas comparer sans tenir compte des accents : sa collation NOCASE ne couvre que l'ASCII et il n'existe pas de unaccent() integre. La seule facon d'obtenir « Emile » vers « Émile » sans charger tout le catalogue en memoire est de stocker la forme mise a plat, et d'y chercher un terme passe par exactement la meme fonction. NormalisationTexte fait cette mise a plat. La ponctuation y devient une espace au lieu de disparaitre : « P.F. Hamilton » doit donner trois mots, sans quoi le rapprochement par initiales serait impossible. RapprochementAuteurs porte la frontiere decidee dans CLAUDE.md. La cle de regroupement — mots normalises tries — couvre casse, accents et ordre inverse, et servira d'index unique. Les initiales abregees lui echappent : elles demandent d'aligner les mots un a un, avec retour arriere, parce qu'une correspondance gloutonne echouerait sur « P. Peter » face a « Peter Paul ». Le verdict est a trois niveaux, et l'asymetrie est deliberee : ne pas fusionner laisse deux fiches a reunir d'un clic, alors qu'une fusion erronee melange les livres de deux personnes et se defait mal. Un patronyme seul face a un nom complet ressort donc « suggere », jamais « certain ». Meme exigence sur l'alignement complet : « P. H. » et « Peter Hamilton » s'alignent par les seules initiales, ce qui ne concorde sur rien de verifiable — un mot entier commun est exige. Co-Authored-By: Claude Opus 5 --- MaBibli.Shared/Textes/NormalisationTexte.cs | 77 +++++++++ MaBibli.Shared/Textes/RapprochementAuteurs.cs | 160 ++++++++++++++++++ MaBibli.Tests/RapprochementAuteursTests.cs | 121 +++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 MaBibli.Shared/Textes/NormalisationTexte.cs create mode 100644 MaBibli.Shared/Textes/RapprochementAuteurs.cs create mode 100644 MaBibli.Tests/RapprochementAuteursTests.cs diff --git a/MaBibli.Shared/Textes/NormalisationTexte.cs b/MaBibli.Shared/Textes/NormalisationTexte.cs new file mode 100644 index 0000000..ead925d --- /dev/null +++ b/MaBibli.Shared/Textes/NormalisationTexte.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Text; + +namespace MaBibli.Shared.Textes; + +/// +/// Mise à plat d'un texte pour la recherche et le regroupement : minuscules, sans accents, +/// sans ponctuation, espaces réduits. +/// +/// +/// Pourquoi une forme stockée plutôt qu'une comparaison à la volée ? SQLite ne sait pas +/// comparer sans tenir compte des accents : sa collation NOCASE ne couvre que l'ASCII, et +/// il n'existe pas de unaccent() intégré. Faire le travail en C# à l'écriture, dans une +/// colonne dédiée, est la seule façon d'obtenir « Emile » → « Émile » sans charger tout +/// le catalogue en mémoire à chaque recherche. +/// +/// Conséquence à ne pas oublier : toute écriture doit recalculer la forme normalisée, et le +/// terme cherché doit passer par la même fonction que la valeur stockée. +/// +/// +public static class NormalisationTexte +{ + /// + /// Forme comparable d'un texte quelconque : « Émile ZOLA » → « emile zola ». + /// + /// + /// La ponctuation devient une espace plutôt que de disparaître : « P.F. Hamilton » doit + /// donner trois mots (p f hamilton) et non un seul (pf hamilton), sans quoi + /// le rapprochement par initiales serait impossible. + /// + public static string Normaliser(string? brut) + { + if (string.IsNullOrWhiteSpace(brut)) + { + return string.Empty; + } + + // NFD sépare la lettre de son accent ; on ne garde ensuite que la lettre. + var decompose = brut.Normalize(NormalizationForm.FormD); + var sortie = new StringBuilder(decompose.Length); + var espaceEnAttente = false; + + foreach (var c in decompose) + { + if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) + { + continue; // Accent détaché par la décomposition. + } + + if (char.IsLetterOrDigit(c)) + { + if (espaceEnAttente && sortie.Length > 0) + { + sortie.Append(' '); + } + + espaceEnAttente = false; + sortie.Append(char.ToLowerInvariant(c)); + continue; + } + + // Tout le reste (espaces, points, virgules, tirets…) est un séparateur de mots. + espaceEnAttente = true; + } + + return sortie.ToString().Normalize(NormalizationForm.FormC); + } + + /// Mots de la forme normalisée, sans mot vide. + public static string[] Mots(string? brut) + { + var normalise = Normaliser(brut); + return normalise.Length == 0 + ? [] + : normalise.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } +} diff --git a/MaBibli.Shared/Textes/RapprochementAuteurs.cs b/MaBibli.Shared/Textes/RapprochementAuteurs.cs new file mode 100644 index 0000000..d442925 --- /dev/null +++ b/MaBibli.Shared/Textes/RapprochementAuteurs.cs @@ -0,0 +1,160 @@ +namespace MaBibli.Shared.Textes; + +/// Verdict du rapprochement de deux noms d'auteur. +public enum NiveauRapprochement +{ + /// Rien de commun : ce sont deux auteurs distincts. + Aucun = 0, + + /// + /// Variante sûre : même nom à la casse, aux accents, à l'ordre ou à l'abréviation près. + /// Peut être fusionné automatiquement. + /// + Certain = 1, + + /// + /// Rapprochement plausible mais ambigu : un nom est contenu dans l'autre + /// (« Hamilton » vers « Peter F. Hamilton »). À proposer, jamais à appliquer seul. + /// + Suggere = 2, +} + +/// +/// Règles de rapprochement des noms d'auteurs. +/// +/// +/// Décision actée dans CLAUDE.md : le regroupement automatique se limite aux variantes +/// sûres — casse, accents, ordre Nom, PrénomPrénom Nom, initiales abrégées +/// quand le reste concorde. Tout le reste est proposé à l'utilisateur. +/// +/// La raison est asymétrique : ne pas fusionner laisse deux fiches à réunir d'un clic, alors +/// qu'une fusion erronée mélange les livres de deux personnes et se défait mal. Dans le doute, +/// on ne fusionne pas. +/// +/// +public static class RapprochementAuteurs +{ + /// + /// Clé de regroupement : mots normalisés triés, ce qui rend l'ordre indifférent. + /// + /// + /// « Zola, Émile » et « Émile Zola » donnent tous deux emile zola. C'est cette clé qui + /// porte l'unicité en base : deux auteurs ne peuvent pas coexister avec la même. + /// + /// Elle ne couvre pas les initiales (p f hamiltonf hamilton peter) : + /// ce cas relève de , qui aligne les mots au lieu de les hacher. + /// + /// + public static string Cle(string? nom) + { + var mots = NormalisationTexte.Mots(nom); + Array.Sort(mots, StringComparer.Ordinal); + return string.Join(' ', mots); + } + + /// + /// Compare deux noms d'auteur et dit s'ils désignent sûrement, peut-être, ou sûrement pas + /// la même personne. + /// + public static NiveauRapprochement Comparer(string? a, string? b) + { + var motsA = NormalisationTexte.Mots(a); + var motsB = NormalisationTexte.Mots(b); + + if (motsA.Length == 0 || motsB.Length == 0) + { + return NiveauRapprochement.Aucun; + } + + if (motsA.Length == motsB.Length) + { + // Même nombre de mots : soit tout s'aligne (variante sûre), soit ce sont deux personnes. + // On exige au moins un mot entier commun : « P. H. » et « Peter Hamilton » s'alignent + // par les seules initiales, ce qui ne concorde sur rien de vérifiable. + return Aligne(motsA, motsB, out var pleinPartage) && pleinPartage + ? NiveauRapprochement.Certain + : NiveauRapprochement.Aucun; + } + + // Nombres de mots différents : au mieux l'un est un fragment de l'autre + // (« Hamilton » ⊂ « Peter F. Hamilton »). Jamais sûr — deux personnes partagent + // couramment un patronyme. + var (court, longue) = motsA.Length < motsB.Length ? (motsA, motsB) : (motsB, motsA); + + return Aligne(court, longue, out var motPleinPartage) && motPleinPartage + ? NiveauRapprochement.Suggere + : NiveauRapprochement.Aucun; + } + + /// Raccourci : les deux noms sont-ils une variante sûre l'un de l'autre ? + public static bool SontLeMemeAuteur(string? a, string? b) => + Comparer(a, b) == NiveauRapprochement.Certain; + + /// + /// Cherche à placer chaque mot de sur un mot distinct de + /// , par égalité ou par initiale. + /// + /// + /// Vrai si au moins un couple aligné est une égalité de mots entiers. Sans cette + /// garantie, « P. » se rapprocherait de n'importe quel prénom commençant par P — une + /// suggestion sans valeur qui n'apprendrait rien à l'utilisateur. + /// + /// + /// Recherche exhaustive avec retour arrière. Les noms de personnes tiennent en quelques mots, + /// le coût est négligeable ; une correspondance gloutonne, elle, échouerait sur + /// « P. Peter » vs « Peter Paul » en consommant le mauvais mot en premier. + /// + private static bool Aligne(string[] aPlacer, string[] cible, out bool motPleinPartage) + { + var pris = new bool[cible.Length]; + motPleinPartage = false; + return Placer(aPlacer, cible, pris, 0, ref motPleinPartage); + } + + private static bool Placer( + string[] aPlacer, string[] cible, bool[] pris, int index, ref bool motPleinPartage) + { + if (index == aPlacer.Length) + { + return true; + } + + var mot = aPlacer[index]; + + for (var i = 0; i < cible.Length; i++) + { + if (pris[i]) + { + continue; + } + + var autre = cible[i]; + var egalite = mot == autre; + var initiale = !egalite && EstInitialeDe(mot, autre); + + if (!egalite && !initiale) + { + continue; + } + + pris[i] = true; + var pleinAvant = motPleinPartage; + motPleinPartage = pleinAvant || (egalite && mot.Length > 1); + + if (Placer(aPlacer, cible, pris, index + 1, ref motPleinPartage)) + { + return true; + } + + pris[i] = false; + motPleinPartage = pleinAvant; + } + + return false; + } + + /// « p » est l'initiale de « peter ». Une lettre seule d'un côté, le mot de l'autre. + private static bool EstInitialeDe(string a, string b) => + (a.Length == 1 && b.Length > 1 && b[0] == a[0]) + || (b.Length == 1 && a.Length > 1 && a[0] == b[0]); +} diff --git a/MaBibli.Tests/RapprochementAuteursTests.cs b/MaBibli.Tests/RapprochementAuteursTests.cs new file mode 100644 index 0000000..da3309e --- /dev/null +++ b/MaBibli.Tests/RapprochementAuteursTests.cs @@ -0,0 +1,121 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Tests; + +/// +/// Règles de regroupement des noms d'auteurs. +/// +/// +/// La frontière testée ici est celle que CLAUDE.md pose : ce qui est sûr est fusionné, ce qui +/// est ambigu est proposé. Les tests sont écrits par catégorie plutôt que par cas, pour que +/// l'ajout d'une règle oblige à dire de quel côté de la frontière elle tombe. +/// +public class RapprochementAuteursTests +{ + [Theory] + // Casse et accents. + [InlineData("Émile Zola", "emile zola")] + [InlineData("Émile Zola", "EMILE ZOLA")] + [InlineData("Émile Zola", "Emile Zola")] + // Ordre inversé, avec ou sans virgule. + [InlineData("Zola, Émile", "Émile Zola")] + [InlineData("Zola, Emile", "emile zola")] + // Initiales abrégées, le reste concordant. + [InlineData("Peter F. Hamilton", "P.F. Hamilton")] + [InlineData("Peter F. Hamilton", "P. F. Hamilton")] + [InlineData("P.F. Hamilton", "P. F. Hamilton")] + [InlineData("Thomas H. Cormen", "T. H. Cormen")] + // Combinaison des trois. + [InlineData("Hamilton, Peter F.", "p f hamilton")] + // Auteur mononyme. + [InlineData("Homère", "homere")] + public void Les_variantes_sures_sont_reconnues(string a, string b) + { + Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(a, b)); + Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(b, a)); + } + + [Theory] + // Le cas d'école de CLAUDE.md : un patronyme seul face à un nom complet. + [InlineData("Hamilton", "Peter F. Hamilton")] + [InlineData("Zola", "Émile Zola")] + [InlineData("Cormen", "Thomas H. Cormen")] + // Un prénom en plus, ou en moins. + [InlineData("Émile Zola", "Émile Édouard Zola")] + public void Les_rapprochements_ambigus_sont_seulement_proposes(string a, string b) + { + Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(a, b)); + Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(b, a)); + + // Et surtout : jamais fusionnés d'office. + Assert.False(RapprochementAuteurs.SontLeMemeAuteur(a, b)); + } + + [Theory] + // Deux personnes qui partagent un patronyme : rien ne doit les rapprocher automatiquement. + [InlineData("Peter Hamilton", "Paul Hamilton")] + [InlineData("Peter F. Hamilton", "Peter G. Hamilton")] + // Homonymes partiels sans mot entier commun. + [InlineData("Émile Zola", "Émile Ajar")] + [InlineData("Voltaire", "Molière")] + // Une initiale seule ne concorde sur rien de vérifiable. + [InlineData("P. H.", "Peter Hamilton")] + [InlineData("P. Hamilton", "Peter Paul")] + public void Les_auteurs_distincts_ne_sont_pas_rapproches(string a, string b) + { + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(a, b)); + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(b, a)); + } + + [Fact] + public void Un_nom_vide_ne_se_rapproche_de_rien() + { + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(null, "Émile Zola")); + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(" ", "Émile Zola")); + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(".,;", "Émile Zola")); + } + + [Fact] + public void La_cle_est_identique_pour_les_variantes_dordre_et_daccents() + { + var attendue = RapprochementAuteurs.Cle("Émile Zola"); + + Assert.Equal(attendue, RapprochementAuteurs.Cle("Zola, Émile")); + Assert.Equal(attendue, RapprochementAuteurs.Cle("emile zola")); + Assert.Equal(attendue, RapprochementAuteurs.Cle(" ZOLA, Emile ")); + Assert.Equal("emile zola", attendue); + } + + /// + /// La clé ne sait pas rapprocher les initiales : c'est justement pour ça que + /// existe en plus de l'index unique. + /// + [Fact] + public void La_cle_ne_couvre_pas_les_initiales() + { + Assert.NotEqual( + RapprochementAuteurs.Cle("Peter F. Hamilton"), + RapprochementAuteurs.Cle("P.F. Hamilton")); + + Assert.True(RapprochementAuteurs.SontLeMemeAuteur("Peter F. Hamilton", "P.F. Hamilton")); + } + + [Theory] + [InlineData("Émile ZOLA", "emile zola")] + [InlineData("P.F. Hamilton", "p f hamilton")] + [InlineData(" Zola, Émile ", "zola emile")] + [InlineData("Saint-Exupéry", "saint exupery")] + [InlineData("L'Écume", "l ecume")] + [InlineData("", "")] + [InlineData(null, "")] + public void La_normalisation_met_a_plat_casse_accents_et_ponctuation(string? brut, string attendu) + => Assert.Equal(attendu, NormalisationTexte.Normaliser(brut)); + + /// + /// La ponctuation devient une espace et ne disparaît pas : sans ça « P.F. » donnerait un seul + /// mot et les initiales ne pourraient plus s'aligner. + /// + [Fact] + public void La_ponctuation_separe_les_mots() + => Assert.Equal(["p", "f", "hamilton"], NormalisationTexte.Mots("P.F. Hamilton")); +}