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; /// /// Commande « traduire » : décrit le pack réellement présent sur le disque, puis /// exécute le vrai pipeline de traduction /// (OnnxTranslationService) hors d'un téléphone. /// /// /// Le moteur est retrouvé par réflexion, et c'est délibéré. /// OnnxRuntimeInferenceSessionFactory n'existe dans l'assembly /// Omika.App.Local que si le symbole OMIKA_ONNX_RUNTIME 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 avant /// de l'activer. La réflexion laisse donc l'outil compiler dans les deux états, /// sans référencer Microsoft.ML.OnnxRuntime, et fait basculer cette /// commande toute seule le jour où le moteur est activé. /// /// 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 "); string texte = arguments.Option("texte") ?? throw new ErreurUsageException("option obligatoire manquante : --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// » : 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; } } /// /// 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. /// 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)}"); } } /// /// Fabrique d'inférence réellement disponible : celle du moteur si le /// symbole OMIKA_ONNX_RUNTIME est défini dans /// Omika.App.Local, sinon le repli de l'application — qui répond /// « indisponible » au lieu de planter. /// 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; } /// /// Affiche le jeton de cible que le pack déclare (modele.json), 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. /// 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}"); } } /// /// Vocabulaire que le service retiendra : vocab.spm s'il est présent, /// sinon le premier .spm du dossier — même règle que /// OnnxTranslationService, reproduite ici uniquement pour que le temps /// mesuré porte sur le fichier réellement utilisé. /// 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; } /// /// Sens demandé, comparé aux valeurs canoniques 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. /// private static SensTraduction? LireSens(string valeur) { foreach (SensTraduction sens in Enum.GetValues()) { if (string.Equals(sens.VersCode(), valeur, StringComparison.OrdinalIgnoreCase)) { return sens; } } return null; } /// Rappel du rôle métier de chaque sens (dictée ou lecture), pour lever tout doute. private static string LibelleSens(SensTraduction sens) => sens == SensTraduction.VersFrancais ? "langue secondaire → français (dictée)" : "français → langue secondaire (lecture)"; }