333 lines
14 KiB
C#
333 lines
14 KiB
C#
using System.Diagnostics;
|
|
using System.Reflection;
|
|
using Omika.App.Local.Abstractions;
|
|
using Omika.App.Local.Infrastructure;
|
|
using Omika.App.Local.Models;
|
|
using Omika.App.Local.Services;
|
|
using Omika.Core.Contracts.Mobile.Languages;
|
|
|
|
namespace Omika.LanguesProbe;
|
|
|
|
/// <summary>
|
|
/// Commande « traduire » : décrit le pack réellement présent sur le disque, puis
|
|
/// exécute le <b>vrai</b> pipeline de traduction
|
|
/// (<c>OnnxTranslationService</c>) hors d'un téléphone.
|
|
///
|
|
/// <para>
|
|
/// <b>Le moteur est retrouvé par réflexion, et c'est délibéré.</b>
|
|
/// <c>OnnxRuntimeInferenceSessionFactory</c> n'existe dans l'assembly
|
|
/// <c>Omika.App.Local</c> que si le symbole <c>OMIKA_ONNX_RUNTIME</c> y est
|
|
/// défini : une référence directe ne compilerait pas tant que le moteur n'est
|
|
/// pas embarqué, ce qui interdirait justement d'utiliser cet outil <b>avant</b>
|
|
/// de l'activer. La réflexion laisse donc l'outil compiler dans les deux états,
|
|
/// sans référencer <c>Microsoft.ML.OnnxRuntime</c>, et fait basculer cette
|
|
/// commande toute seule le jour où le moteur est activé.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class TraductionEssai
|
|
{
|
|
private const string NomTypeMoteur = "Omika.App.Local.Services.OnnxRuntimeInferenceSessionFactory";
|
|
|
|
public static int Traduire(string[] args)
|
|
{
|
|
Arguments arguments = Arguments.Analyser(args, "dossier", "sens", "texte", "code");
|
|
string dossier = arguments.Option("dossier")
|
|
?? throw new ErreurUsageException("option obligatoire manquante : --dossier <dossierDuPack>");
|
|
string texte = arguments.Option("texte")
|
|
?? throw new ErreurUsageException("option obligatoire manquante : --texte \"<texte>\"");
|
|
string code = arguments.Option("code", "es");
|
|
string sensDemande = arguments.Option("sens", LanguageModelDirections.VersFrancais);
|
|
|
|
if (!LanguagePaths.EstCodeValide(code))
|
|
{
|
|
throw new ErreurUsageException(
|
|
$"code de langue invalide « {code} » (minuscules ASCII et tirets, 2 à 10 caractères : es, pt-br)");
|
|
}
|
|
|
|
SensTraduction? sens = LireSens(sensDemande)
|
|
?? throw new ErreurUsageException(
|
|
$"sens inconnu « {sensDemande} » : attendu {LanguageModelDirections.VersFrancais} "
|
|
+ $"ou {LanguageModelDirections.DepuisFrancais}");
|
|
|
|
if (string.IsNullOrWhiteSpace(texte))
|
|
{
|
|
throw new ErreurUsageException("le texte à traduire est vide");
|
|
}
|
|
|
|
// Le dossier est traité comme « langues/<code>/<sens> » : c'est le contrat de la commande.
|
|
if (!Directory.Exists(dossier))
|
|
{
|
|
Console.Error.WriteLine($"ÉCHEC : le dossier « {Program.NomFichier(dossier)} » est introuvable.");
|
|
return CodesSortie.OperationEchouee;
|
|
}
|
|
|
|
string sensCode = sens.Value.VersCode();
|
|
var magasin = new PackLangueDossier(dossier, code, sensCode);
|
|
PackLangueInstalle pack = magasin.DecrirePack();
|
|
|
|
Console.WriteLine("── Pack présent sur le disque ──");
|
|
Console.WriteLine($" Dossier : {Program.NomFichier(dossier)} Langue : {code} Sens : {sensCode}");
|
|
|
|
if (pack.Fichiers.Count == 0)
|
|
{
|
|
Console.Error.WriteLine("ÉCHEC : ce dossier ne contient aucun fichier .spm ni .onnx.");
|
|
return CodesSortie.OperationEchouee;
|
|
}
|
|
|
|
foreach (FichierModeleInstalle fichier in pack.Fichiers)
|
|
{
|
|
Console.WriteLine($" {fichier.NomFichier,-22} {LangueDisponible.FormaterTaille(fichier.TailleOctets),10}");
|
|
}
|
|
|
|
// L'inventaire ne dit pas QUEL fichier le service retiendra : le rappeler évite de chercher
|
|
// longtemps pourquoi « encoder_v2.onnx » n'est pas celui qui a été chargé.
|
|
Console.WriteLine($" Priorité du service : {OnnxTranslationService.FichierVocabulaire}, "
|
|
+ $"{OnnxTranslationService.FichierEncodeur}, puis {OnnxTranslationService.FichierDecodeur} "
|
|
+ "(sinon le premier fichier du dossier)");
|
|
|
|
// Un jeton de cible mal déclaré produirait la mauvaise langue SANS erreur :
|
|
// l'afficher est la première chose à vérifier quand une traduction « sonne faux ».
|
|
AfficherMetadonnees(magasin, code, sensCode);
|
|
|
|
FichierModeleInstalle? vocabulaire = VocabulaireRetenu(pack);
|
|
if (vocabulaire is null)
|
|
{
|
|
Console.Error.WriteLine("ÉCHEC : aucun vocabulaire .spm dans ce dossier.");
|
|
return CodesSortie.OperationEchouee;
|
|
}
|
|
|
|
var chrono = Stopwatch.StartNew();
|
|
Console.WriteLine();
|
|
Console.WriteLine("── Vocabulaire ──");
|
|
|
|
SentencePieceTokenizer tokenizer;
|
|
try
|
|
{
|
|
// Chargé ici en plus du service (qui le recharge pour lui) : cela donne le temps réel du
|
|
// fichier et fait échouer la commande AVANT le chargement des modèles, qui coûte
|
|
// autrement plus cher qu'un diagnostic de vocabulaire.
|
|
tokenizer = SentencePieceTokenizer.Charger(magasin.CheminFichier(code, sensCode, vocabulaire.NomFichier));
|
|
}
|
|
catch (SentencePieceException ex)
|
|
{
|
|
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
|
|
return CodesSortie.OperationEchouee;
|
|
}
|
|
|
|
chrono.Stop();
|
|
Console.WriteLine($" {vocabulaire.NomFichier} chargé en {chrono.ElapsedMilliseconds} ms — "
|
|
+ $"{tokenizer.TailleVocabulaire} pièces, {tokenizer.Avertissements.Count} avertissement(s)");
|
|
Console.WriteLine($" Normalisation : {tokenizer.ProfilNormalisation}");
|
|
Console.WriteLine(" Détail (aller-retour, repli octet, pièces) : commandes « inspecter » et « encoder ».");
|
|
|
|
IOnnxInferenceSessionFactory fabrique = ResoudreFabrique();
|
|
Console.WriteLine();
|
|
Console.WriteLine("── Moteur d'inférence ──");
|
|
Console.WriteLine($" Moteur : {fabrique.NomMoteur}");
|
|
Console.WriteLine($" Disponible : {(fabrique.EstDisponible ? "oui" : "non")}");
|
|
|
|
if (!fabrique.EstDisponible)
|
|
{
|
|
Program.AfficherMoteurAbsent();
|
|
return CodesSortie.OperationEchouee;
|
|
}
|
|
|
|
// Le service est libéré en fin de commande (ses sessions ONNX avec lui) : un modèle de
|
|
// plusieurs dizaines de Mo ne doit pas survivre à un outil de diagnostic.
|
|
using var service = new OnnxTranslationService(magasin, fabrique, new OnnxTranslationOptions());
|
|
if (!service.EstInstalleeAsync(code, sens.Value).GetAwaiter().GetResult())
|
|
{
|
|
Console.Error.WriteLine("ÉCHEC : le service ne reconnaît pas ce dossier comme un pack exploitable");
|
|
Console.Error.WriteLine(" (vocabulaire ou modèle .onnx manquant pour ce sens).");
|
|
return CodesSortie.OperationEchouee;
|
|
}
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine("── Chargement des modèles (session jetable, libérée aussitôt) ──");
|
|
foreach (FichierModeleInstalle fichier in pack.Fichiers)
|
|
{
|
|
if (fichier.NomFichier.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
MesurerChargementModele(fabrique, magasin, code, sensCode, fichier);
|
|
}
|
|
}
|
|
|
|
// Écart le plus fréquent entre deux exports d'un même modèle : les noms d'entrées/sorties.
|
|
// Le service les lit dans OnnxTranslationOptions ; les afficher rend l'écart visible.
|
|
Console.WriteLine(" Noms attendus par OnnxTranslationOptions : input_ids, attention_mask,");
|
|
Console.WriteLine(" decoder_input_ids, encoder_hidden_states, encoder_attention_mask, logits.");
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine("── Traduction ──");
|
|
Console.WriteLine($" Sens : {sensCode} ({LibelleSens(sens.Value)})");
|
|
Console.WriteLine($" Source : {texte}");
|
|
|
|
chrono.Restart();
|
|
try
|
|
{
|
|
string traduction = service.TraduireAsync(texte, code, sens.Value).GetAwaiter().GetResult();
|
|
chrono.Stop();
|
|
Console.WriteLine($" Résultat : {traduction}");
|
|
Console.WriteLine($" Durée : {chrono.ElapsedMilliseconds} ms (chargement des modèles et inférence compris)");
|
|
return CodesSortie.Succes;
|
|
}
|
|
catch (TranslationException ex)
|
|
{
|
|
chrono.Stop();
|
|
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
|
|
|
|
// Moteur absent au premier appel (bibliothèque native manquante, symbole non défini) :
|
|
// c'est l'échec attendu d'une machine de développement, pas un défaut du pack.
|
|
if (ex.Message.Contains("indisponible", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Program.AfficherMoteurAbsent();
|
|
}
|
|
|
|
return CodesSortie.OperationEchouee;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ouvre un modèle pour en mesurer le seul chargement, et publier au
|
|
/// passage les noms d'entrées/sorties réels : un modèle corrompu est ainsi
|
|
/// désigné nommément, avant la traduction.
|
|
/// </summary>
|
|
private static void MesurerChargementModele(
|
|
IOnnxInferenceSessionFactory fabrique,
|
|
PackLangueDossier magasin,
|
|
string code,
|
|
string sens,
|
|
FichierModeleInstalle fichier)
|
|
{
|
|
var chrono = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
using IOnnxInferenceSession session = fabrique.Ouvrir(magasin.CheminFichier(code, sens, fichier.NomFichier));
|
|
chrono.Stop();
|
|
Console.WriteLine($" {fichier.NomFichier,-22} {chrono.ElapsedMilliseconds,6} ms");
|
|
Console.WriteLine($" entrées : {string.Join(", ", session.NomsEntrees)}");
|
|
Console.WriteLine($" sorties : {string.Join(", ", session.NomsSorties)}");
|
|
}
|
|
catch (TranslationException ex)
|
|
{
|
|
chrono.Stop();
|
|
Console.WriteLine($" {fichier.NomFichier,-22} non mesurable : {Program.MessageSansChemin(ex.Message)}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fabrique d'inférence réellement disponible : celle du moteur si le
|
|
/// symbole <c>OMIKA_ONNX_RUNTIME</c> est défini dans
|
|
/// <c>Omika.App.Local</c>, sinon le repli de l'application — qui répond
|
|
/// « indisponible » au lieu de planter.
|
|
/// </summary>
|
|
private static IOnnxInferenceSessionFactory ResoudreFabrique()
|
|
{
|
|
try
|
|
{
|
|
Type? typeMoteur = typeof(OnnxTranslationService).Assembly.GetType(NomTypeMoteur, throwOnError: false);
|
|
object? instance = typeMoteur
|
|
?.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)
|
|
?.GetValue(null);
|
|
|
|
if (instance is IOnnxInferenceSessionFactory fabrique)
|
|
{
|
|
return fabrique;
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Le type existe mais sa bibliothèque native manque : ce n'est pas une erreur de l'outil,
|
|
// c'est exactement l'état « moteur indisponible » que le repli représente.
|
|
}
|
|
|
|
return UnavailableOnnxInferenceSessionFactory.Instance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Affiche le jeton de cible que le pack déclare (<c>modele.json</c>), tel que
|
|
/// le moteur l'enverra réellement au modèle. Un fichier absent est l'état
|
|
/// normal d'un modèle dédié à une paire ; un fichier illisible est signalé
|
|
/// ici plutôt que découvert au milieu d'une traduction.
|
|
/// </summary>
|
|
private static void AfficherMetadonnees(PackLangueDossier magasin, string code, string sens)
|
|
{
|
|
string chemin = magasin.CheminFichier(code, sens, ModelePackMetadonnees.NomFichier);
|
|
if (!File.Exists(chemin))
|
|
{
|
|
Console.WriteLine(" Jeton cible : aucun (modèle dédié à une paire)");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
ModelePackMetadonnees? metadonnees = ModelePackMetadonnees.DepuisJson(File.ReadAllText(chemin));
|
|
if (metadonnees is null)
|
|
{
|
|
Console.WriteLine($" Jeton cible : aucun ({ModelePackMetadonnees.NomFichier} vide)");
|
|
return;
|
|
}
|
|
|
|
string modele = string.IsNullOrEmpty(metadonnees.ModeleSource) ? "non déclaré" : metadonnees.ModeleSource;
|
|
string jeton = metadonnees.AjouteUnJeton ? metadonnees.JetonCible : "aucun";
|
|
Console.WriteLine($" Jeton cible : {jeton} (modèle {modele})");
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
Console.WriteLine($" Jeton cible : ILLISIBLE — {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vocabulaire que le service retiendra : <c>vocab.spm</c> s'il est présent,
|
|
/// sinon le premier <c>.spm</c> du dossier — même règle que
|
|
/// <c>OnnxTranslationService</c>, reproduite ici uniquement pour que le temps
|
|
/// mesuré porte sur le fichier réellement utilisé.
|
|
/// </summary>
|
|
private static FichierModeleInstalle? VocabulaireRetenu(PackLangueInstalle pack)
|
|
{
|
|
FichierModeleInstalle? premier = null;
|
|
|
|
foreach (FichierModeleInstalle fichier in pack.Fichiers)
|
|
{
|
|
if (!fichier.NomFichier.EndsWith(".spm", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(fichier.NomFichier, OnnxTranslationService.FichierVocabulaire, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return fichier;
|
|
}
|
|
|
|
premier ??= fichier;
|
|
}
|
|
|
|
return premier;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sens demandé, comparé aux valeurs <b>canoniques</b> du manifeste plutôt
|
|
/// qu'à des chaînes écrites à la main. La casse est tolérée parce que le sens
|
|
/// est aussi tapé à la main sur un poste de développement.
|
|
/// </summary>
|
|
private static SensTraduction? LireSens(string valeur)
|
|
{
|
|
foreach (SensTraduction sens in Enum.GetValues<SensTraduction>())
|
|
{
|
|
if (string.Equals(sens.VersCode(), valeur, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return sens;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Rappel du rôle métier de chaque sens (dictée ou lecture), pour lever tout doute.</summary>
|
|
private static string LibelleSens(SensTraduction sens)
|
|
=> sens == SensTraduction.VersFrancais
|
|
? "langue secondaire → français (dictée)"
|
|
: "français → langue secondaire (lecture)";
|
|
}
|