Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
+121
View File
@@ -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/&lt;code&gt;/&lt;sens&gt;</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
/// (« &lt;code&gt;\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é.");
}
+225
View File
@@ -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;
}
+332
View File
@@ -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)";
}