Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Arguments d'une commande, séparés en positionnels et options nommées.
|
||||
///
|
||||
/// <para>
|
||||
/// L'analyse est volontairement stricte : une option inconnue est <b>refusée</b>
|
||||
/// au lieu d'être ignorée. Sur un poste de développement, une faute de frappe
|
||||
/// silencieuse (<c>--dossiers</c>, <c>--sens</c> mal orthographié) ferait
|
||||
/// travailler l'outil sur autre chose que ce que le développeur croit, et le
|
||||
/// diagnostic serait faux sans que rien ne le signale.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Les valeurs ne sont jamais redécoupées : un chemin contenant des espaces
|
||||
/// arrive en un seul argument, grâce aux guillemets du shell, et le reste.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class Arguments
|
||||
{
|
||||
private readonly List<string> _positionnels = [];
|
||||
private readonly Dictionary<string, string> _options = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private Arguments()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyse les arguments d'une commande.
|
||||
/// </summary>
|
||||
/// <param name="args">Arguments suivant le nom de la commande.</param>
|
||||
/// <param name="optionsAttendues">Options nommées acceptées, sans les tirets.</param>
|
||||
/// <exception cref="AideDemandeeException"><c>--aide</c> ou <c>-h</c> est présent.</exception>
|
||||
/// <exception cref="ErreurUsageException">Option inconnue, ou option sans valeur.</exception>
|
||||
public static Arguments Analyser(string[] args, params string[] optionsAttendues)
|
||||
{
|
||||
var resultat = new Arguments();
|
||||
var connues = new HashSet<string>(optionsAttendues, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
string argument = args[i];
|
||||
|
||||
if (argument is "--aide" or "-h")
|
||||
{
|
||||
// Reconnu à n'importe quelle position : « traduire --aide » doit expliquer la
|
||||
// commande, pas réclamer --dossier.
|
||||
throw new AideDemandeeException();
|
||||
}
|
||||
|
||||
if (!argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
resultat._positionnels.Add(SansGuillemets(argument));
|
||||
continue;
|
||||
}
|
||||
|
||||
string nom = argument[2..];
|
||||
if (!connues.Contains(nom))
|
||||
{
|
||||
throw new ErreurUsageException($"option inconnue « {argument} »");
|
||||
}
|
||||
|
||||
if (i + 1 >= args.Length)
|
||||
{
|
||||
throw new ErreurUsageException($"l'option « {argument} » attend une valeur");
|
||||
}
|
||||
|
||||
// La valeur est consommée telle quelle, même si elle commence par « -- » : un texte à
|
||||
// traduire peut légitimement commencer par un tiret.
|
||||
resultat._options[nom] = SansGuillemets(args[++i]);
|
||||
}
|
||||
|
||||
return resultat;
|
||||
}
|
||||
|
||||
/// <summary>Argument positionnel, ou erreur d'usage s'il manque.</summary>
|
||||
public string Positionnel(int index, string description)
|
||||
=> index < _positionnels.Count
|
||||
? _positionnels[index]
|
||||
: throw new ErreurUsageException($"argument manquant : {description}");
|
||||
|
||||
/// <summary>Valeur d'une option, ou <see langword="null"/> si elle est absente.</summary>
|
||||
public string? Option(string nom)
|
||||
=> _options.TryGetValue(nom, out string? valeur) ? valeur : null;
|
||||
|
||||
/// <summary>Valeur d'une option, ou <paramref name="defaut"/> si elle est absente.</summary>
|
||||
public string Option(string nom, string defaut) => Option(nom) ?? defaut;
|
||||
|
||||
/// <summary>Positionnels à partir d'un index (un texte peut arriver en plusieurs arguments).</summary>
|
||||
public IReadOnlyList<string> Reste(int depuis)
|
||||
{
|
||||
if (depuis >= _positionnels.Count)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return _positionnels.GetRange(depuis, _positionnels.Count - depuis);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retire une paire de guillemets entourant une valeur : un copier-coller
|
||||
/// depuis un document, ou un shell qui transmet les guillemets eux-mêmes,
|
||||
/// produirait sinon un chemin de fichier introuvable.
|
||||
/// </summary>
|
||||
private static string SansGuillemets(string valeur)
|
||||
=> valeur.Length >= 2 && valeur[0] == '"' && valeur[^1] == '"' ? valeur[1..^1] : valeur;
|
||||
}
|
||||
|
||||
/// <summary>Argument manquant, option inconnue : l'appel est incorrect (code de sortie 1).</summary>
|
||||
internal sealed class ErreurUsageException : Exception
|
||||
{
|
||||
public ErreurUsageException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>--aide</c> demandé : ce n'est pas une erreur (code de sortie 0).</summary>
|
||||
internal sealed class AideDemandeeException : Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.App.Local\Omika.App.Local.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,213 @@
|
||||
using Omika.App.Local.Abstractions;
|
||||
using Omika.App.Local.Models;
|
||||
using Omika.Core.Contracts.Mobile.Languages;
|
||||
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Magasin de pack de langue adossé à un dossier du disque : c'est ce qui permet
|
||||
/// d'exécuter le <b>vrai</b> pipeline (<c>OnnxTranslationService</c>) sur les
|
||||
/// fichiers d'un développeur, hors téléphone.
|
||||
///
|
||||
/// <para>
|
||||
/// Le dossier passé en ligne de commande est traité comme
|
||||
/// « <c>langues/<code>/<sens></c> » : tous les fichiers
|
||||
/// <c>*.spm</c> et <c>*.onnx</c> qu'il contient sont déclarés pour le sens
|
||||
/// demandé, avec leur taille réelle. Aucune empreinte SHA-256 n'est inventée :
|
||||
/// un pack décrit depuis le disque n'a pas de référence serveur à laquelle se
|
||||
/// comparer, et une fausse empreinte rendrait la vérification mensongère.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Lecture seule.</b> Les méthodes d'écriture de l'interface refusent d'agir
|
||||
/// plutôt que de faire semblant : un outil de diagnostic ne doit jamais
|
||||
/// modifier, ni supprimer, ce qu'un développeur lui demande d'examiner.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class PackLangueDossier : ILanguagePackStore
|
||||
{
|
||||
private readonly string _dossier;
|
||||
private readonly string _code;
|
||||
private readonly string _sens;
|
||||
|
||||
public PackLangueDossier(string dossier, string code, string sens)
|
||||
{
|
||||
_dossier = dossier;
|
||||
_code = code;
|
||||
_sens = sens;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dossier partagé entre les deux sens d'une même langue
|
||||
/// (« <code>\commun »), frère du dossier examiné. Un modèle familial
|
||||
/// (<c>opus-mt-tc-big-itc-itc</c>) y range ses poids une seule fois, et chaque
|
||||
/// sens ne porte que son <c>modele.json</c>. Absent pour un pack bilingue.
|
||||
/// </summary>
|
||||
private string DossierCommun
|
||||
{
|
||||
get
|
||||
{
|
||||
string? parent = Path.GetDirectoryName(_dossier);
|
||||
return string.IsNullOrEmpty(parent)
|
||||
? string.Empty
|
||||
: Path.Combine(parent, LanguageModelDirections.Commun);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Répertoire racine des packs sur l'appareil — ici, le dossier examiné.</summary>
|
||||
public string Racine => _dossier;
|
||||
|
||||
/// <summary>
|
||||
/// Aucun manifeste serveur n'est disponible hors application : le retour
|
||||
/// <see langword="null"/> est l'état « mémoire d'index absente », déjà géré
|
||||
/// par les appelants.
|
||||
/// </summary>
|
||||
public Task<LanguageManifestResponse?> GetIndexAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<LanguageManifestResponse?>(null);
|
||||
|
||||
public Task EnregistrerIndexAsync(LanguageManifestResponse manifeste, CancellationToken cancellationToken = default)
|
||||
=> throw LectureSeule();
|
||||
|
||||
public Task<PackLangueInstalle?> GetPackInstalleAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PackLangueInstalle?>(DecrirePack());
|
||||
|
||||
public Task<IReadOnlyList<PackLangueInstalle>> ListerPacksInstallesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packs = new List<PackLangueInstalle>();
|
||||
if (Directory.Exists(_dossier))
|
||||
{
|
||||
packs.Add(DecrirePack());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PackLangueInstalle>>(packs);
|
||||
}
|
||||
|
||||
public Task EnregistrerPackAsync(PackLangueInstalle pack, CancellationToken cancellationToken = default)
|
||||
=> throw LectureSeule();
|
||||
|
||||
public Task SupprimerPackAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> throw LectureSeule();
|
||||
|
||||
public string PreparerFichierPartiel(string code, string sens, string nomFichier)
|
||||
=> throw LectureSeule();
|
||||
|
||||
/// <summary>
|
||||
/// Décrit ce qui est <b>réellement présent</b> : c'est cette description qui
|
||||
/// décide des fichiers que le service ouvrira, donc ce que l'outil mesure.
|
||||
/// </summary>
|
||||
public PackLangueInstalle DecrirePack()
|
||||
{
|
||||
var fichiers = new List<FichierModeleInstalle>();
|
||||
|
||||
if (Directory.Exists(_dossier))
|
||||
{
|
||||
// Tri par nom : deux exécutions successives doivent produire exactement le même
|
||||
// inventaire, sans quoi une comparaison entre deux mesures n'aurait aucun sens.
|
||||
string[] chemins = Directory.GetFiles(_dossier);
|
||||
Array.Sort(chemins, StringComparer.Ordinal);
|
||||
|
||||
foreach (string chemin in chemins)
|
||||
{
|
||||
string nom = Path.GetFileName(chemin);
|
||||
if (!nom.EndsWith(".spm", StringComparison.OrdinalIgnoreCase)
|
||||
&& !nom.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fichiers.Add(new FichierModeleInstalle
|
||||
{
|
||||
Sens = _sens,
|
||||
NomFichier = nom,
|
||||
TailleOctets = new FileInfo(chemin).Length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Poids partagés d'un modèle familial : le service les résout APRÈS le
|
||||
// dossier du sens (voir OnnxTranslationService.FichiersDuSens). Les
|
||||
// omettre ferait déclarer « pack incomplet » un pack familial valide.
|
||||
string commun = DossierCommun;
|
||||
if (commun.Length > 0 && Directory.Exists(commun))
|
||||
{
|
||||
string[] cheminsCommuns = Directory.GetFiles(commun);
|
||||
Array.Sort(cheminsCommuns, StringComparer.Ordinal);
|
||||
|
||||
foreach (string chemin in cheminsCommuns)
|
||||
{
|
||||
string nom = Path.GetFileName(chemin);
|
||||
if (!nom.EndsWith(".spm", StringComparison.OrdinalIgnoreCase)
|
||||
&& !nom.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fichiers.Add(new FichierModeleInstalle
|
||||
{
|
||||
Sens = LanguageModelDirections.Commun,
|
||||
NomFichier = nom,
|
||||
TailleOctets = new FileInfo(chemin).Length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new PackLangueInstalle
|
||||
{
|
||||
Code = _code,
|
||||
// Le libellé vient du manifeste serveur, absent ici ; il n'est lu par aucun appel du
|
||||
// pipeline de traduction.
|
||||
Libelle = _code,
|
||||
Version = string.Empty,
|
||||
InstalleLeUtc = DateTimeOffset.UtcNow,
|
||||
Fichiers = fichiers
|
||||
};
|
||||
}
|
||||
|
||||
public bool FichierPresent(string code, FichierModeleInstalle fichier)
|
||||
=> File.Exists(CheminFichier(code, fichier.Sens, fichier.NomFichier));
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la présence et la taille relevée à l'inventaire. L'empreinte
|
||||
/// SHA-256 n'est pas recalculée : le pack étant décrit depuis le disque
|
||||
/// lui-même, il n'existe aucune empreinte attendue à confronter.
|
||||
/// </summary>
|
||||
public Task<bool> VerifierFichierAsync(
|
||||
string code, FichierModeleInstalle fichier, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var info = new FileInfo(CheminFichier(code, fichier.Sens, fichier.NomFichier));
|
||||
bool conforme = info.Exists && (fichier.TailleOctets <= 0 || info.Length == fichier.TailleOctets);
|
||||
return Task.FromResult(conforme);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chemin réel d'un fichier du pack. Le nom est réduit à son nom simple,
|
||||
/// comme dans <c>LanguagePaths</c> : un nom de fichier ne doit jamais
|
||||
/// permettre de sortir du dossier examiné. Un fichier absent du dossier du
|
||||
/// sens est cherché dans son frère <c>commun</c> (poids partagé d'un modèle
|
||||
/// familial), en suivant exactement la priorité du service.
|
||||
/// </summary>
|
||||
public string CheminFichier(string code, string sens, string nomFichier)
|
||||
{
|
||||
string nom = Path.GetFileName(nomFichier);
|
||||
string chemin = Path.Combine(_dossier, nom);
|
||||
if (File.Exists(chemin))
|
||||
{
|
||||
return chemin;
|
||||
}
|
||||
|
||||
string commun = DossierCommun;
|
||||
if (commun.Length > 0)
|
||||
{
|
||||
string partage = Path.Combine(commun, nom);
|
||||
if (File.Exists(partage))
|
||||
{
|
||||
return partage;
|
||||
}
|
||||
}
|
||||
|
||||
return chemin;
|
||||
}
|
||||
|
||||
private static NotSupportedException LectureSeule()
|
||||
=> new("Cet outil de diagnostic ne modifie jamais le dossier examiné.");
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Omika.LanguesProbe — vérifie un couple de traduction hors ligne (FR ⇄ ES)
|
||||
/// sur le poste de développement, à partir des fichiers réels du pack.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Pourquoi cet outil.</b> Tant que le symbole <c>OMIKA_ONNX_RUNTIME</c> n'est
|
||||
/// pas défini, le moteur d'inférence n'est embarqué dans aucune tête de
|
||||
/// livraison : contrôler un <c>vocab.spm</c> et un <c>*.onnx</c> réels
|
||||
/// (identifiants des jetons techniques, normalisation, aller-retour du
|
||||
/// découpage, traduction complète) supposait un déploiement sur téléphone. Ici
|
||||
/// les fichiers sont lus directement sur le disque : c'est le véhicule
|
||||
/// d'exécution qui permet de décider de l'activation du moteur à partir de
|
||||
/// mesures, et non de suppositions.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Aucun paquet NuGet supplémentaire</b> : l'outil n'utilise que du code déjà
|
||||
/// présent dans <c>Omika.App.Local</c>, qui compile et s'exécute sans ONNX
|
||||
/// Runtime.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Codes de sortie</b> : 0 succès, 1 erreur d'usage (argument manquant,
|
||||
/// option ou commande inconnue), 2 opération en échec (fichier absent,
|
||||
/// vocabulaire refusé, traduction échouée).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Version lue dans les métadonnées d'assembly, elle-même issue de
|
||||
/// <c>Directory.Build.props</c> : un littéral ici dériverait de la version de
|
||||
/// la solution sans que personne ne s'en aperçoive.
|
||||
/// </summary>
|
||||
private static readonly string VersionOutil =
|
||||
typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? typeof(Program).Assembly.GetName().Version?.ToString()
|
||||
?? "version inconnue";
|
||||
|
||||
// Motif des chemins absolus (Windows « C:\a\b » comme Unix « /a/b »), réduits au seul nom de
|
||||
// fichier dans les messages : un diagnostic ne doit pas divulguer l'arborescence de la machine.
|
||||
private static readonly Regex CheminAbsolu = new(@"(?:[A-Za-z]:)?(?:[\\/][^\\/\s""']+){2,}", RegexOptions.Compiled);
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Executer(args);
|
||||
}
|
||||
catch (AideDemandeeException)
|
||||
{
|
||||
// « --aide » demandé au milieu d'une commande : ce n'est pas une erreur.
|
||||
AfficherAide();
|
||||
return CodesSortie.Succes;
|
||||
}
|
||||
catch (ErreurUsageException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"ERREUR D'USAGE : {ex.Message}");
|
||||
Console.Error.WriteLine("Utilisez « --aide » pour la liste des commandes.");
|
||||
return CodesSortie.ErreurUsage;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Aucune trace technique ne sort d'ici : le message d'une exception .NET peut contenir
|
||||
// le chemin absolu d'un fichier. Le nom de fichier seul suffit à agir.
|
||||
Console.Error.WriteLine($"ÉCHEC : {MessageSansChemin(ex.Message)}");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom de fichier seul, pour tout message destiné à l'écran. Retourne une
|
||||
/// mention neutre plutôt que le chemin brut quand le chemin n'a pas de nom
|
||||
/// exploitable (chemin racine, chemin terminé par un séparateur).
|
||||
/// </summary>
|
||||
internal static string NomFichier(string chemin)
|
||||
{
|
||||
string nom = Path.GetFileName(chemin.TrimEnd('\\', '/'));
|
||||
return string.IsNullOrEmpty(nom) ? "(élément sans nom)" : nom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les chemins absolus d'un message par leur seul nom de fichier.
|
||||
/// Les messages de ce dépôt évitent déjà les chemins, mais ceux du framework
|
||||
/// (<c>IOException</c>, <c>FileNotFoundException</c>) n'en donnent aucune
|
||||
/// garantie : le filtre est donc appliqué à la frontière, une seule fois.
|
||||
/// </summary>
|
||||
internal static string MessageSansChemin(string message)
|
||||
=> CheminAbsolu.Replace(message, m => Path.GetFileName(m.Value));
|
||||
|
||||
/// <summary>
|
||||
/// Rappel actionnable quand aucun moteur d'inférence n'est utilisable : sans
|
||||
/// lui, l'échec serait incompréhensible alors que c'est l'état attendu de la
|
||||
/// machine de développement.
|
||||
/// </summary>
|
||||
internal static void AfficherMoteurAbsent()
|
||||
{
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(" La traduction ne peut pas être calculée : le moteur d'inférence n'est pas");
|
||||
Console.Error.WriteLine(" compilé dans Omika.App.Local (symbole OMIKA_ONNX_RUNTIME absent).");
|
||||
Console.Error.WriteLine(" → Activer le moteur : docs\\VALIDATION_TRADUCTION_ONNX.md");
|
||||
Console.Error.WriteLine(" (procédure et état actuels : docs\\TRADUCTION_HORS_LIGNE_MOBILE.md, §7)");
|
||||
Console.Error.WriteLine(" → « inspecter » et « encoder » restent utilisables sans moteur.");
|
||||
}
|
||||
|
||||
private static int Executer(string[] args)
|
||||
{
|
||||
PreparerConsole();
|
||||
AfficherEnTete();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
// L'aide est la réponse utile, mais l'appel est incomplet : le code de sortie le dit,
|
||||
// pour qu'un script distingue « commande absente » de « commande réussie ».
|
||||
AfficherAide();
|
||||
return CodesSortie.ErreurUsage;
|
||||
}
|
||||
|
||||
string commande = args[0].Trim().ToLowerInvariant();
|
||||
string[] reste = args[1..];
|
||||
|
||||
switch (commande)
|
||||
{
|
||||
case "--aide" or "-h" or "aide":
|
||||
AfficherAide();
|
||||
return CodesSortie.Succes;
|
||||
case "inspecter":
|
||||
return InspectionVocabulaire.Inspecter(reste);
|
||||
case "encoder":
|
||||
return InspectionVocabulaire.Encoder(reste);
|
||||
case "traduire":
|
||||
return TraductionEssai.Traduire(reste);
|
||||
default:
|
||||
Console.Error.WriteLine($"ERREUR D'USAGE : commande inconnue « {args[0]} ».");
|
||||
AfficherAide();
|
||||
return CodesSortie.ErreurUsage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sortie en UTF-8 : les messages sont en français et l'affichage des pièces
|
||||
/// utilise le marqueur d'espace ▁ du modèle. Sans cela, une console Windows
|
||||
/// restée sur une page de codes héritée rendrait des caractères de
|
||||
/// remplacement — un diagnostic illisible ne sert à rien. Un échec est
|
||||
/// ignoré : une sortie redirigée n'a pas de console à reconfigurer.
|
||||
/// </summary>
|
||||
private static void PreparerConsole()
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Aucune console interactive : l'encodage demandé par l'appelant fait foi.
|
||||
}
|
||||
}
|
||||
|
||||
private static void AfficherEnTete()
|
||||
=> Console.WriteLine(
|
||||
$"Omika.LanguesProbe {VersionOutil} — validation d'un couple de traduction hors ligne (FR ⇄ ES), "
|
||||
+ "sans déploiement." + Environment.NewLine);
|
||||
|
||||
private static void AfficherAide()
|
||||
{
|
||||
Console.WriteLine(
|
||||
"""
|
||||
COMMANDES
|
||||
inspecter <vocab.spm>
|
||||
Lit un vocabulaire SentencePiece réel et affiche : nombre de pièces,
|
||||
identifiants des jetons <unk> / <s> / </s> / <pad> (« absent » vaut -1),
|
||||
profil de normalisation, repli octet, avertissements du tokeniseur, puis un
|
||||
aller-retour texte → identifiants → texte sur des phrases françaises et
|
||||
espagnoles. Ne nécessite AUCUN moteur ONNX.
|
||||
|
||||
encoder <vocab.spm> <texte…>
|
||||
Découpe un texte et affiche chaque identifiant avec la pièce qu'il porte, dans
|
||||
l'ordre. Sert à comparer avec un tokeniseur de référence (sentencepiece,
|
||||
transformers) pour localiser une divergence de découpage.
|
||||
Le texte peut être cité entre guillemets ou donné en plusieurs arguments.
|
||||
|
||||
traduire --dossier <dossierDuPack> --texte "<texte>" [--sens <sens>] [--code <code>]
|
||||
Décrit le pack RÉELLEMENT présent dans <dossierDuPack> (fichiers *.spm et
|
||||
*.onnx), puis exécute le pipeline complet de OnnxTranslationService : résultat,
|
||||
temps de chargement du vocabulaire, temps de chargement de chaque modèle et
|
||||
durée de la traduction. Le dossier est traité comme « langues/es/<sens> » :
|
||||
ses fichiers sont lus tels quels, rien n'y est écrit ni modifié.
|
||||
--sens versFrancais (défaut) | depuisFrancais
|
||||
--code code de langue du pack (défaut : es)
|
||||
--texte texte à traduire (obligatoire)
|
||||
|
||||
--aide, -h
|
||||
Affiche cette aide.
|
||||
|
||||
CODES DE SORTIE
|
||||
0 succès
|
||||
1 erreur d'usage (argument manquant, option ou commande inconnue)
|
||||
2 opération en échec (fichier absent, vocabulaire refusé, traduction échouée)
|
||||
|
||||
SANS MOTEUR ONNX RUNTIME
|
||||
Le moteur n'est compilé que sous le symbole OMIKA_ONNX_RUNTIME (voir
|
||||
docs\VALIDATION_TRADUCTION_ONNX.md ; état actuel : docs\TRADUCTION_HORS_LIGNE_MOBILE.md,
|
||||
§7). « inspecter » et « encoder » fonctionnent sans lui ; « traduire » explique alors
|
||||
que la traduction est indisponible, sans jamais afficher de trace technique.
|
||||
|
||||
EXEMPLES
|
||||
Omika.LanguesProbe inspecter "C:\packs\es\versFrancais\vocab.spm"
|
||||
Omika.LanguesProbe encoder "C:\packs\es\versFrancais\vocab.spm" "Bonjour, comment allez-vous ?"
|
||||
Omika.LanguesProbe traduire --dossier "C:\packs\es\versFrancais" --texte "La factura se enviará mañana."
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Codes de sortie du programme, énoncés aussi par <c>--aide</c>.</summary>
|
||||
internal static class CodesSortie
|
||||
{
|
||||
internal const int Succes = 0;
|
||||
internal const int ErreurUsage = 1;
|
||||
internal const int OperationEchouee = 2;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
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)";
|
||||
}
|
||||
Reference in New Issue
Block a user