Files
2026-10-23 18:46:50 +02:00

286 lines
11 KiB
C#

using System.Text;
using Omika.App.Local.Services;
namespace Omika.LanguesProbe;
/// <summary>
/// Commandes « inspecter » et « encoder » : tout ce qui se vérifie sur le
/// <b>vocabulaire seul</b>, sans moteur d'inférence.
///
/// <para>
/// C'est la partie utilisable dès aujourd'hui, moteur éteint : un vocabulaire
/// illisible, un profil de normalisation approximé, un repli octet absent ou un
/// découpage non réversible s'observent ici, alors qu'ils ne se verraient en
/// production que sous forme de traductions dégradées.
/// </para>
/// </summary>
internal static class InspectionVocabulaire
{
/// <summary>
/// Phrases témoins de l'aller-retour : elles cumulent ce qui casse un
/// découpage réel — accents, ponctuation espagnole inversée, apostrophes,
/// chiffres et unités souvent hors vocabulaire, majuscules en début de
/// phrase (le marqueur d'espace y est présent).
/// </summary>
private static readonly string[] PhrasesTemoins =
[
"Bonjour, comment allez-vous ?",
"Le client a signé le devis hier après-midi.",
"Rendez-vous demain matin à 8 h 30.",
"L'intervention est terminée : 1 234,56 € de pièces.",
"¿Dónde está el cliente?",
"La factura se enviará mañana por la mañana.",
"Necesito hablar con el responsable de la obra.",
"El técnico llegó a las nueve y revisó la caldera."
];
/// <summary>Commande « inspecter » : identité et comportement du vocabulaire.</summary>
public static int Inspecter(string[] args)
{
Arguments arguments = Arguments.Analyser(args);
string chemin = arguments.Positionnel(0, "chemin du vocabulaire (.spm)");
SentencePieceTokenizer tokenizer;
try
{
tokenizer = SentencePieceTokenizer.Charger(chemin);
}
catch (SentencePieceException ex)
{
return EchecVocabulaire(ex);
}
Console.WriteLine("── Vocabulaire ──");
Console.WriteLine($" Fichier : {Program.NomFichier(chemin)}");
Console.WriteLine($" Pièces : {tokenizer.TailleVocabulaire}");
Console.WriteLine($" Jetons <unk>/<s>/</s>/<pad> : {TexteIdentifiant(tokenizer.IdTokenInconnu)} / {TexteIdentifiant(tokenizer.IdDebut)}"
+ $" / {TexteIdentifiant(tokenizer.IdFin)} / {TexteIdentifiant(tokenizer.IdRemplissage)}");
Console.WriteLine($" Normalisation : {tokenizer.ProfilNormalisation}");
// Le repli octet décide du sort des caractères hors vocabulaire : accents perdus ou
// préservés. C'est la seconde chose à vérifier après les jetons techniques.
Console.WriteLine(tokenizer.RepliOctetDisponible
? " Repli octet : disponible (un caractère hors vocabulaire est découpé en octets UTF-8)"
: " Repli octet : INDISPONIBLE (un caractère hors vocabulaire devient <unk>, donc perdu)");
// Le type de segmentation (UNIGRAM / BPE) vit dans le trainer_spec du .spm, que le
// tokeniseur lit sans le publier : aucune propriété ne l'expose. L'annoncer demanderait de
// le déduire du seul comportement, ce qui serait une supposition — on dit qu'il est
// indéterminable ici plutôt que de laisser croire à une mesure.
Console.WriteLine(" Segmentation : non déterminable ici (UNIGRAM/BPE non exposé par SentencePieceTokenizer)");
Console.WriteLine();
Console.WriteLine("── Avertissements du tokeniseur ──");
if (tokenizer.Avertissements.Count == 0)
{
Console.WriteLine(" aucun avertissement");
}
else
{
foreach (string avertissement in tokenizer.Avertissements)
{
Console.WriteLine($" - {avertissement}");
}
}
Console.WriteLine();
Console.WriteLine("── Aller-retour : texte → identifiants → texte ──");
Console.WriteLine(" DIFFERENT signale un découpage non réversible : à comparer avec un tokeniseur de référence.");
int ecarts = 0;
foreach (string phrase in PhrasesTemoins)
{
if (!AllerRetour(tokenizer, phrase))
{
ecarts++;
}
}
Console.WriteLine();
Console.WriteLine(ecarts == 0
? $"VERDICT : aller-retour IDENTIQUE sur les {PhrasesTemoins.Length} phrases témoins."
: $"VERDICT : {ecarts} phrase(s) sur {PhrasesTemoins.Length} ne reviennent pas identiques (premier écart indiqué).");
return CodesSortie.Succes;
}
/// <summary>Commande « encoder » : identifiants et pièces, dans l'ordre.</summary>
public static int Encoder(string[] args)
{
Arguments arguments = Arguments.Analyser(args, "texte");
string chemin = arguments.Positionnel(0, "chemin du vocabulaire (.spm)");
// Un texte non cité arrive en plusieurs arguments : les recoller avec des espaces évite de
// ne traduire que le premier mot.
string texte = arguments.Option("texte") ?? string.Join(' ', arguments.Reste(1));
if (string.IsNullOrEmpty(texte))
{
throw new ErreurUsageException("texte manquant : « encoder <vocab.spm> \"<texte>\" »");
}
SentencePieceTokenizer tokenizer;
try
{
tokenizer = SentencePieceTokenizer.Charger(chemin);
}
catch (SentencePieceException ex)
{
return EchecVocabulaire(ex);
}
IReadOnlyList<int> ids = tokenizer.EncodeAsync(texte).GetAwaiter().GetResult();
Console.WriteLine($"Vocabulaire : {Program.NomFichier(chemin)} ({tokenizer.TailleVocabulaire} pièces)");
Console.WriteLine($"Texte : {texte}");
Console.WriteLine($"Identifiants ({ids.Count}) : {string.Join(' ', ids)}");
Console.WriteLine();
Console.WriteLine(" # id pièce");
for (int i = 0; i < ids.Count; i++)
{
Console.WriteLine($" {i,-5} {ids[i],-8} {TextePiece(tokenizer, ids[i])}");
}
return CodesSortie.Succes;
}
/// <summary>Aller-retour d'une phrase témoin ; vrai si le texte revient identique.</summary>
private static bool AllerRetour(SentencePieceTokenizer tokenizer, string phrase)
{
// L'encodage est synchrone et sans E/S : l'attente directe est légitime dans une
// application console, qui n'a pas de contexte de synchronisation à préserver.
IReadOnlyList<int> ids = tokenizer.EncodeAsync(phrase).GetAwaiter().GetResult();
string decode = tokenizer.Decode(ids);
bool identique = string.Equals(decode, phrase, StringComparison.Ordinal);
Console.WriteLine();
Console.WriteLine($" « {phrase} »");
Console.WriteLine($" identifiants ({ids.Count}) : {AfficherIds(ids)}");
Console.WriteLine($" décodé : « {decode} »");
if (identique)
{
Console.WriteLine(" VERDICT : IDENTIQUE");
}
else
{
// Le premier caractère divergent désigne la pièce fautive : le développeur n'a pas à
// comparer les deux chaînes à l'œil.
int position = PremiereDivergence(phrase, decode);
Console.WriteLine(
$" VERDICT : DIFFERENT — premier écart au caractère {position} : "
+ $"« {CaractereLisible(phrase, position)} » attendu, « {CaractereLisible(decode, position)} » obtenu");
}
return identique;
}
/// <summary>
/// Texte d'une pièce, tel que le tokeniseur le restitue.
///
/// <para>
/// La table des pièces n'est pas publiée (elle est privée) : la seule lecture
/// possible passe par <see cref="SentencePieceTokenizer.Decode"/> sur un
/// identifiant isolé. Deux conséquences, rendues visibles à l'affichage :
/// </para>
/// <list type="bullet">
/// <item>le marqueur d'espace ▁ est retraduit en espace puis rogné en bord de
/// chaîne : il est retrouvé en décodant <b>deux fois</b> le même identifiant,
/// le second exemplaire révélant l'espace de séparation ;</item>
/// <item>une pièce d'octet isolée ne forme pas un caractère UTF-8 valide et se
/// décode en U+FFFD — c'est en soi l'indice d'un fragment d'octet. Un octet
/// ASCII, lui, s'affiche comme le caractère correspondant, qui <b>est</b> sa
/// valeur.</item>
/// </list>
/// </summary>
private static string TextePiece(SentencePieceTokenizer tokenizer, int id)
{
if (id == tokenizer.IdTokenInconnu)
{
return "<unk> — jeton technique";
}
if (id == tokenizer.IdDebut)
{
return "<s> — jeton technique";
}
if (id == tokenizer.IdFin)
{
return "</s> — jeton technique";
}
if (id == tokenizer.IdRemplissage)
{
return "<pad> — jeton technique";
}
string seul = tokenizer.Decode([id]);
if (seul.Length == 0)
{
return "(aucun texte : jeton de contrôle ou identifiant hors vocabulaire)";
}
if (seul.All(c => c == '\uFFFD'))
{
return "<octet> — fragment d'octet, non affichable isolément";
}
bool porteMarqueur = tokenizer.Decode([id, id]).Length == (2 * seul.Length) + 1;
return porteMarqueur ? $"▁{seul}" : seul;
}
/// <summary>Liste d'identifiants, tronquée quand elle dépasse ce qui reste lisible.</summary>
private static string AfficherIds(IReadOnlyList<int> ids)
{
const int maximum = 24;
var texte = new StringBuilder();
for (int i = 0; i < Math.Min(ids.Count, maximum); i++)
{
if (i > 0)
{
texte.Append(' ');
}
texte.Append(ids[i]);
}
if (ids.Count > maximum)
{
texte.Append($" … (+{ids.Count - maximum} identifiants)");
}
return texte.ToString();
}
private static int PremiereDivergence(string attendu, string obtenu)
{
int commun = Math.Min(attendu.Length, obtenu.Length);
for (int i = 0; i < commun; i++)
{
if (attendu[i] != obtenu[i])
{
return i;
}
}
return commun;
}
private static string CaractereLisible(string texte, int position)
=> position >= texte.Length ? "fin de chaîne" : texte[position].ToString();
/// <summary>Identifiant d'un jeton technique, « absent » pour -1 (pièce absente du vocabulaire).</summary>
private static string TexteIdentifiant(int id) => id >= 0 ? id.ToString() : "absent";
/// <summary>
/// Le message de <see cref="SentencePieceException"/> est déjà destiné à
/// l'utilisateur (français, sans chemin) : il est affiché tel quel.
/// </summary>
private static int EchecVocabulaire(SentencePieceException ex)
{
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
return CodesSortie.OperationEchouee;
}
}