Normaliser les textes et poser les regles de rapprochement d'auteurs
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MaBibli.Shared.Textes;
|
||||
|
||||
/// <summary>
|
||||
/// Mise à plat d'un texte pour la recherche et le regroupement : minuscules, sans accents,
|
||||
/// sans ponctuation, espaces réduits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Pourquoi une forme stockée plutôt qu'une comparaison à la volée ?</b> SQLite ne sait pas
|
||||
/// comparer sans tenir compte des accents : sa collation <c>NOCASE</c> ne couvre que l'ASCII, et
|
||||
/// il n'existe pas de <c>unaccent()</c> intégré. Faire le travail en C# à l'écriture, dans une
|
||||
/// colonne dédiée, est la seule façon d'obtenir « Emile » → « Émile » <b>sans</b> charger tout
|
||||
/// le catalogue en mémoire à chaque recherche.
|
||||
/// <para>
|
||||
/// Conséquence à ne pas oublier : toute écriture doit recalculer la forme normalisée, et le
|
||||
/// terme cherché doit passer par la <b>même</b> fonction que la valeur stockée.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class NormalisationTexte
|
||||
{
|
||||
/// <summary>
|
||||
/// Forme comparable d'un texte quelconque : « Émile ZOLA » → « emile zola ».
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La ponctuation devient une espace plutôt que de disparaître : « P.F. Hamilton » doit
|
||||
/// donner trois mots (<c>p f hamilton</c>) et non un seul (<c>pf hamilton</c>), sans quoi
|
||||
/// le rapprochement par initiales serait impossible.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Mots de la forme normalisée, sans mot vide.</summary>
|
||||
public static string[] Mots(string? brut)
|
||||
{
|
||||
var normalise = Normaliser(brut);
|
||||
return normalise.Length == 0
|
||||
? []
|
||||
: normalise.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
namespace MaBibli.Shared.Textes;
|
||||
|
||||
/// <summary>Verdict du rapprochement de deux noms d'auteur.</summary>
|
||||
public enum NiveauRapprochement
|
||||
{
|
||||
/// <summary>Rien de commun : ce sont deux auteurs distincts.</summary>
|
||||
Aucun = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Variante <b>sûre</b> : même nom à la casse, aux accents, à l'ordre ou à l'abréviation près.
|
||||
/// Peut être fusionné automatiquement.
|
||||
/// </summary>
|
||||
Certain = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Rapprochement <b>plausible mais ambigu</b> : un nom est contenu dans l'autre
|
||||
/// (« Hamilton » vers « Peter F. Hamilton »). À <b>proposer</b>, jamais à appliquer seul.
|
||||
/// </summary>
|
||||
Suggere = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles de rapprochement des noms d'auteurs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Décision actée dans CLAUDE.md : le regroupement <b>automatique</b> se limite aux variantes
|
||||
/// sûres — casse, accents, ordre <c>Nom, Prénom</c> ↔ <c>Prénom Nom</c>, initiales abrégées
|
||||
/// quand le reste concorde. Tout le reste est <b>proposé</b> à l'utilisateur.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RapprochementAuteurs
|
||||
{
|
||||
/// <summary>
|
||||
/// Clé de regroupement : mots normalisés <b>triés</b>, ce qui rend l'ordre indifférent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// « Zola, Émile » et « Émile Zola » donnent tous deux <c>emile zola</c>. C'est cette clé qui
|
||||
/// porte l'unicité en base : deux auteurs ne peuvent pas coexister avec la même.
|
||||
/// <para>
|
||||
/// Elle ne couvre <b>pas</b> les initiales (<c>p f hamilton</c> ≠ <c>f hamilton peter</c>) :
|
||||
/// ce cas relève de <see cref="Comparer"/>, qui aligne les mots au lieu de les hacher.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string Cle(string? nom)
|
||||
{
|
||||
var mots = NormalisationTexte.Mots(nom);
|
||||
Array.Sort(mots, StringComparer.Ordinal);
|
||||
return string.Join(' ', mots);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare deux noms d'auteur et dit s'ils désignent sûrement, peut-être, ou sûrement pas
|
||||
/// la même personne.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Raccourci : les deux noms sont-ils une variante sûre l'un de l'autre ?</summary>
|
||||
public static bool SontLeMemeAuteur(string? a, string? b) =>
|
||||
Comparer(a, b) == NiveauRapprochement.Certain;
|
||||
|
||||
/// <summary>
|
||||
/// Cherche à placer chaque mot de <paramref name="aPlacer"/> sur un mot distinct de
|
||||
/// <paramref name="cible"/>, par égalité ou par initiale.
|
||||
/// </summary>
|
||||
/// <param name="motPleinPartage">
|
||||
/// Vrai si au moins un couple aligné est une égalité de <b>mots entiers</b>. 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.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>« p » est l'initiale de « peter ». Une lettre seule d'un côté, le mot de l'autre.</summary>
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user