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)";
}